mobileapp

package
v0.16.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 24, 2026 License: Apache-2.0 Imports: 30 Imported by: 0

Documentation

Overview

Package mobileapp — see deps.go for the consumer-boundary interfaces that handlers depend on. The intent (per Slice 0 / NFR-002) is that handler code reaches the store, engine, and SSE hub through these minimal interfaces rather than the concrete `*state.Store`, `*engine.Engine`, and `*Hub` types — which makes handler-level tests cheap (no temp dirs, no real engine wiring) and confines the concrete-type blast radius to the constructor.

The interface methods are deliberately the SMALLEST set each handler family needs — adding a method here is a deliberate "I want this callable from an unrelated handler" signal, not a default. As more handlers migrate to interface-based DI in later slices, methods get added narrowly. Concrete types in `internal/state` and `internal/engine` already satisfy these interfaces by structural match, so wiring stays drop-in (see server.go for the constructor).

Package mobileapp — handlers_daihyosen.go owns the `POST /api/competitions/:cid/matches/:mid/daihyosen` endpoint that validates a tied knockout-stage team match and appends a daihyosen (representative-bout) placeholder to its SubResults (T140, FR-046, CHK026).

The endpoint is structurally a sibling of `/decision` and `/score`: the operator's UI calls this when both teams finish a knockout match with equal IV+PW; the engine returns the placeholder SubMatchResult (Position=-1, Decision="daihyosen") which the handler persists onto the parent match. The operator then fills the rep player names + ippon via the standard score path.

Eligibility integration (CHK026): before validating the tie, the handler counts each team's eligible competitors via the competitor- status store. When either side has zero eligible competitors the engine returns ErrInsufficientEligibility and we respond 409 — the caller MUST then forfeit the encounter to the opposing team via the standard score endpoint (this handler intentionally does NOT auto-record the forfeit so the operator confirms it).

Package mobileapp — handlers_decision.go owns the POST `/api/competitions/:cid/matches/:mid/decision` endpoint that auto- fills the scoreline for kiken/fusenpai/fusensho/daihyosen decisions (T090).

All consumers go through the constructor-injected `ScoringEngine` / `Broadcaster` interfaces from deps.go rather than the concrete `*engine.Engine` / `*Hub` types (NFR-002).

Package mobileapp — handlers_eligibility.go owns the `/api/competitions/:cid/competitor-status` endpoints (T091).

GET returns every persisted status entry for the competition; POST sets (or replaces) a single entry. The frontend subscribes to the SSE `competitor_status_updated` event (T092) to invalidate cached match-list state when a status changes.

All consumers go through CompetitorStatusStore + Broadcaster (deps.go) rather than the concrete *state.Store / *Hub (NFR-002).

Package mobileapp — handlers_lineup.go owns the `/api/competitions/:cid/teams/:tid/lineups/:round` endpoints (Slice 7.B / T127).

GET returns the lineup for a (team, round) tuple, PUT sets/replaces it, DELETE removes it. The lineup is mutable up until the round's first match goes live — once frozen, subsequent PUTs return 409 with ErrLineupLocked (FR-040, FR-041, R4 / CHK012).

All store I/O goes through the TeamLineupStore + CompetitionStore interfaces (deps.go) rather than the concrete *state.Store (NFR-002). The handler needs CompetitionStore to look up the competition's TeamSize, which drives the FIK back-fill validation inside TeamLineup.Validate.

Package mobileapp — see validation.go for the Validate() error pattern that request bodies use after JSON binding (Slice 0 / NFR-004).

Pattern (used by `c.ShouldBindJSON(&req); req.Validate()`):

  1. Define the body as a struct with explicit JSON tags.
  2. Implement `Validate() error` on the struct (pointer receiver when the struct is large) and return a typed `ValidationError` describing the first failed field. Stop on the first failure — handlers map ValidationError to HTTP 400 with the embedded message.
  3. Handlers call `req.Validate()` immediately after `ShouldBindJSON`. Anything more semantic (e.g. cross-resource lookups, store reads) stays in the handler — Validate() handles only request-shape invariants that don't need I/O.

ScoreRequest is the example implementation. Other handler families will adopt the same pattern as later slices touch them.

Index

Constants

View Source
const (
	AutoCompleteErrorHeader = "X-Auto-Complete-Error"
	AutoCompleteErrorValue  = "failed"
)

AutoCompleteErrorHeader is set on score/start responses when the post-write MaybeAutoCompletePools check itself errored. The value is a deliberately generic sentinel (AutoCompleteErrorValue) so we don't leak filesystem paths or other internal store details to clients — full error detail is logged server-side.

View Source
const (
	// DefaultMaxBodyBytes caps non-import admin request bodies. 1 MB is
	// far above any legitimate JSON payload on this server (the largest
	// is a competition with embedded roster ~ a few KB per player ×
	// thousands of players = low hundreds of KB) and well below the
	// point where streaming the body to BindJSON becomes a memory-
	// exhaustion vector.
	DefaultMaxBodyBytes int64 = 1 << 20 // 1 MB

	// MaxImportBodyBytes is the request-size cap for /tournament/import.
	// The handler's ParseMultipartForm(64<<20) is a memory threshold, not
	// a request-size cap — without this middleware-level limit a client
	// could stream a 10 GB body that the form parser would happily spill
	// to disk. 64 MB is sized for a worst-case full-roster CSV plus
	// metadata; raise here if real tournaments outgrow it.
	MaxImportBodyBytes int64 = 64 << 20 // 64 MB
)

Body-size caps for mp-663 Phase 3. The MaxBodyBytes middleware enforces the actual request-size cap; the import handler's existing ParseMultipartForm(64<<20) is only a memory threshold for form parsing (parts above the threshold spill to disk rather than RAM) and does not by itself limit total request size. Keeping MaxImportBodyBytes at the same numeric value just lets the two layers reason about "64 MB" consistently — but the request-size cap lives here, in the MaxBodyBytes middleware applied to the import group in server.go.

View Source
const (
	MaxLenTournamentName     = 200
	MaxLenTournamentVenue    = 200
	MaxLenTournamentDate     = 10  // DD-MM-YYYY, also format-validated
	MaxLenTournamentPassword = 256 // not trimmed; cap prevents megabyte payloads
	MaxLenCeremonyBlock      = 16  // "1h30m" etc.

	MaxLenCompetitionName         = 200
	MaxLenCompetitionNumberPrefix = 3 // matches admin UI maxLength="3"
	MaxLenCompetitionStartTime    = 8 // "HH:MM"
	MaxLenCompetitionDate         = 10

	MaxLenPlayerName        = 100
	MaxLenPlayerDisplayName = 50 // physical zekken fabric-strip size
	MaxLenPlayerDojo        = 100
	MaxLenPlayerMetadata    = 200 // per entry
	MaxPlayerMetadataItems  = 16

	MaxLenMatchSide        = 100 // sideA / sideB / winner
	MaxLenMatchScheduledAt = 32

	MaxLenDecisionReason    = 200
	MaxLenEligibilityReason = 200
	MaxLenEntityID          = 64 // matches state.ValidateCompetitionID cap

	MaxLenSeedAssignmentName = 100

	MaxLenCheckInWindow = 5 // "HH:MM"
)

MaxLen* caps the byte length of persisted user-string fields. Picked loose enough that no realistic operator hits them, tight enough that abusive inputs are rejected fast on the write path. Defense-in-depth against unbounded YAML/CSV inflation — Findings #2 reinterpretation from the security review (the recommended HTML-sanitization path was rejected; render-time encoding via Preact JSX is already in place).

View Source
const DefaultHistorySize = 100

DefaultHistorySize is the default ring buffer capacity for replay-on-reconnect (T216). 100 events is roughly 30 seconds of activity on a busy tournament floor (multi-court bulk score) and matches what we measured in v3 review.

View Source
const DefaultMaxSSEClients = 1000

DefaultMaxSSEClients caps concurrent /api/events subscribers per process. Each subscriber allocates one buffered channel + one streaming goroutine, and Broadcast fan-out is O(N) in the subscriber count. 1000 is well above what a typical tournament floor needs (operator + ~5-20 viewers per court × ~10 courts ≈ 50-200 connections) and well below the point where the linear fan-out becomes a noticeable latency tax.

Override at startup via the SSE_MAX_CLIENTS env var or by passing a positive value to NewHubWithLimits. A non-positive (zero or negative) value disables the cap entirely — used by tests that need to exceed the default and not enforced anywhere production. The cap is mp-663 Phase 4 mitigation for resource-exhaustion via unbounded subscriber maps.

View Source
const MaxLenOriginatorID = 128

MaxLenOriginatorID caps the originatorId at 128 bytes — a UUID is 36 bytes, a fallback random string is ~20; 128 leaves headroom for future variants without letting an attacker pump arbitrary bytes through the SSE channel.

Variables

This section is empty.

Functions

func AuthMiddleware

func AuthMiddleware(verifier PasswordVerifier, store *state.Store) gin.HandlerFunc

AuthMiddleware gates admin endpoints behind the X-Tournament-Password header. The actual credential check is delegated to the PasswordVerifier so file-based and locked (bcrypt-env-var) modes share a single middleware.

The store reference is needed for the "uninitialized tournament" bootstrap branch — that gate fires when no tournament.md exists and we must let through the very first POST /api/tournament. The verifier owns the policy: file verifier allows anonymous bootstrap (no credential exists yet); bcrypt verifier requires the env-var password even at bootstrap so an internet-exposed fresh deployment can't be race-claimed by a network-reachable attacker.

func MaxBodyBytes

func MaxBodyBytes(n int64) gin.HandlerFunc

MaxBodyBytes returns a Gin middleware that rejects requests whose body exceeds n bytes. Two checks, in order of cost:

  1. Fast path — if Content-Length is set and > n, return 413 before reading a single byte (defends against malicious clients that truthfully advertise a huge body so we can drop them cheaply).
  2. Defensive wrap — replace c.Request.Body with http.MaxBytesReader so handlers that don't trust Content-Length (or where the client lied / used chunked encoding) still trip the limit during reading. Handlers that read past n get an *http.MaxBytesError surfaced via c.ShouldBindJSON; the existing 500 path renders the error reasonably even if not optimally — a follow-up could map that specific error to 413 inside BindJSON wrappers.

Skips GET/HEAD/DELETE/OPTIONS — those don't carry a body in practice and wrapping a nil body would surface false errors.

func RegisterAnnouncementHandlers

func RegisterAnnouncementHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub)

func RegisterAuthConfigHandlers

func RegisterAuthConfigHandlers(r *gin.RouterGroup, verifier PasswordVerifier)

RegisterAuthConfigHandlers wires GET /api/auth-config. The endpoint is public (no admin auth header required) — the response carries no sensitive material, only the toggle bits the SPA needs to render its auth UI consistently with the running configuration.

A locked-mode deployment can still expose this safely because the information ("password is in env var, reset is off") is already inferable from any 404 on /api/tournament/reset. Exposing it explicitly lets the SPA branch up-front rather than relying on a 404-probe race during form rendering.

func RegisterCompetitionHandlers

func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub *Hub)

func RegisterDaihyosenHandlers

func RegisterDaihyosenHandlers(r *gin.RouterGroup, eng DaihyosenEngine, store DaihyosenStore, hub Broadcaster)

RegisterDaihyosenHandlers wires the POST /daihyosen endpoint. The caller in server.go passes `*engine.Engine` and `*state.Store` which satisfy the local interfaces by structural match.

T140, FR-046.

func RegisterDecisionHandlers

func RegisterDecisionHandlers(r *gin.RouterGroup, eng ScoringEngine, store CompetitionStore, tx CompetitionTransactor, hub Broadcaster)

RegisterDecisionHandlers wires the POST /decision endpoint via the consumer-boundary interfaces.

T090, NFR-002. T156: under WithTransaction so the match-write + ineligibility-write + (on undo) prior-loser eligibility restore all commit under ONE per-comp lock acquire instead of 3+ separate ones. `tx` (CompetitionTransactor) is the new dependency for that migration; `eng` exposes the tx-aware RecordDecisionTx the closure dispatches to.

SSE broadcasts and the optional tryAutoCompletePools post-write run AFTER the tx returns — the auto-complete check itself takes the lock internally via UpdateCompetitionChanged, so running it inside the tx would deadlock (non-reentrant mutex). Holding the tx open across an SSE broadcast would let a slow consumer stall every other writer for the same competition.

func RegisterDisplayHandlers

func RegisterDisplayHandlers(r *gin.RouterGroup, store *state.Store)

RegisterDisplayHandlers wires the public, no-auth display surfaces used by non-browser streaming integrations (vMix, OBS plugins, scoreboards). The only route today is GET /api/viewer/court/:court/live — a one-shot polled view of the currently-running match on a court. Browser clients should continue to use SSE (/api/events); the polled surface exists for clients that cannot subscribe.

Per NFR-002 the handler depends on the *state.Store concrete type (the same boundary handlers_viewer.go uses); there is no snug-fit interface in deps.go that covers the read methods we need here, and inventing one for a single consumer would be premature. If a second polled surface lands later we can hoist a DisplayStore interface then.

func RegisterEligibilityHandlers

func RegisterEligibilityHandlers(r *gin.RouterGroup, store CompetitorStatusStore, hub Broadcaster)

RegisterEligibilityHandlers wires the write-only POST /competitions/:id/competitor-status endpoint on the admin (auth-protected) router group. The corresponding GET is public and registered via RegisterPublicEligibilityHandlers.

T091, FR-034.

func RegisterImportHandlers

func RegisterImportHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub)

func RegisterLineupHandlers

func RegisterLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, comps CompetitionStore, tx CompetitionTransactor)

RegisterLineupHandlers wires the PUT/DELETE lineup endpoints under the admin (auth-protected) group. The corresponding GET is public and registered via RegisterPublicLineupHandlers.

DELETE is manager-only per the spec — for now we rely on the existing AuthMiddleware (mounted on the admin router group in server.go) as the auth boundary; a richer role check lands when per-role auth is implemented.

The third parameter (`tx CompetitionTransactor`) is the T156 hook. The PUT body wraps its three store calls — load comp (for teamSize), set lineup, reload lineup (for the response) — in one WithTransaction so they all commit under a single per-comp lock acquire. `*state.Store` satisfies all three interfaces (TeamLineupStore + CompetitionStore + CompetitionTransactor) so wiring stays drop-in.

func RegisterMatchHandlers

func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store CompetitionStore, tx CompetitionTransactor, hub *Hub)

RegisterMatchHandlers wires up the score / quick-score / bulk-score / court / override-winner / time endpoints under the admin group.

The score endpoint is the Slice 0 / NFR-002 demonstration of the interface-based dependency injection pattern (T017): it consumes `ScoringEngine` and `Broadcaster` rather than the concrete `*engine.Engine` and `*Hub`, plus the `ScoreRequest.Validate()` pattern (T015 / NFR-004) for request-shape validation.

The remaining endpoints in this file still hold concrete pointers (the function signature accepts the concrete `*engine.Engine` for methods not yet on the interface). Later slices migrate those one at a time; the concrete `*engine.Engine` remains a drop-in implementation of `ScoringEngine` so the `tryAutoCompletePools` and score endpoint paths can already accept the interface today.

func RegisterParticipantHandlers

func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, hub Broadcaster)

func RegisterPublicAnnouncementHandlers

func RegisterPublicAnnouncementHandlers(r *gin.RouterGroup, store *state.Store)

func RegisterPublicEligibilityHandlers

func RegisterPublicEligibilityHandlers(r *gin.RouterGroup, store CompetitorStatusStore)

RegisterPublicEligibilityHandlers wires the read-only GET /competitions/:id/competitor-status endpoint on an unauthenticated router group. This mirrors the other /api/competitions/:id/* viewer GETs: eligibility state is not sensitive (it's derivable from the public match results) and the display/viewer surfaces need it without admin credentials. The write path (POST) stays on the admin group via RegisterEligibilityHandlers.

T091, FR-034.

func RegisterPublicLineupHandlers

func RegisterPublicLineupHandlers(r *gin.RouterGroup, store TeamLineupStore)

RegisterLineupHandlers wires the GET/PUT/DELETE lineup endpoints under the admin group. Slice 7.B / T127.

DELETE is manager-only per the spec — for now we rely on the existing AuthMiddleware (mounted on the admin router group in server.go) as the auth boundary; a richer role check lands when per-role auth is implemented.

The third parameter (`tx CompetitionTransactor`) is the T156 hook. The PUT body wraps its three store calls — load comp (for teamSize), set lineup, reload lineup (for the response) — in one WithTransaction so they all commit under a single per-comp lock acquire. The GET and DELETE paths stay on the lock-per-call form because they're single-operation flows where the extra primitive would just be ceremony. `*state.Store` satisfies all three interfaces (TeamLineupStore + CompetitionStore + CompetitionTransactor) so wiring stays drop-in. RegisterPublicLineupHandlers wires the read-only GET /competitions/:id/teams/:tid/lineups/:round endpoint on an unauthenticated router group. Lineup data (position assignments) is not sensitive — coaches and viewers can see who plays where — and the AdminLineup form needs to load the current lineup without holding admin credentials for the initial read. PUT and DELETE remain on the admin group via RegisterLineupHandlers.

Slice 7.B / T127.

func RegisterPublicSwissHandlers

func RegisterPublicSwissHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine)

RegisterPublicSwissHandlers wires read-only Swiss endpoints under the public /api group. Standings are derived from completed match results, which are themselves public via the viewer endpoint — so spectators, coaches, and TV displays need them without admin credentials. Discovered during the post-merge browser UAT pass: the SwissStandings viewer tab broke with "invalid tournament password" because the admin-only RegisterSwissHandlers was the only registration.

FR-050e.

func RegisterReinstateHandler

func RegisterReinstateHandler(r *gin.RouterGroup, eng EligibilityEngine, hub Broadcaster)

RegisterReinstateHandler wires POST /competitions/:id/competitors/:pid/reinstate on the admin (auth-protected) router group. Restores eligibility for a competitor who was withdrawn via kiken-injury (FIK Art. 30). Voluntary kiken (Art. 31) and fusenpai statuses are not reinstateable.

func RegisterResetHandlers

func RegisterResetHandlers(r *gin.RouterGroup, store *state.Store, verifier PasswordVerifier, hub *Hub)

RegisterResetHandlers wires POST /api/tournament/reset. The route is public (no admin auth header required) because it IS the unlock path for a forgotten password. In locked mode the verifier reports ResetEnabled()==false and the handler 404s — matching the path-doesn't-exist response so a scanner can't differentiate a locked deployment from one that's been compiled without this feature.

func RegisterScheduleHandlers

func RegisterScheduleHandlers(r *gin.RouterGroup)

RegisterScheduleHandlers wires the stateless schedule estimator endpoint under r. T147a, T152a — the endpoint reads no state and holds no auth requirement so it can serve both the CLI web UI (`make run` mode) and the mobile-app frontend (`make run-mobile` mode) with one implementation. The web/js/time_estimator.js fetch caller is the canonical consumer; deck/admin renderers may also hit it for "how long will this take" hints.

FR-059, SC-005, NFR-004.

func RegisterSwissHandlers

func RegisterSwissHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub *Hub)

RegisterSwissHandlers wires the Swiss-format-specific endpoints onto the admin router group:

POST /api/competitions/:id/swiss/generate-round  — generate next round
GET  /api/competitions/:id/swiss/standings       — cumulative standings

Both endpoints sit inside the admin group (same as the rest of the competition write/read endpoints). The standings GET is read-only but matches the existing pattern of pool-standings being inside the admin group as well — viewer-side standings reuse the same Engine helper via the public viewer handlers when needed.

FR-050d, FR-050e.

func RegisterTournamentHandlers

func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub, verifier PasswordVerifier)

func RegisterViewerHandlers

func RegisterViewerHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine)

Types

type Broadcaster

type Broadcaster interface {
	// Broadcast publishes the given event payload to every active SSE
	// subscriber. Mirrors Hub.Broadcast.
	Broadcast(eventType EventType, data any)
}

Broadcaster is the consumer-boundary view of *Hub used by handlers that fire SSE events on successful mutations. Defined as an interface so handler tests can supply a recording stub instead of running a real SSE hub with subscriber goroutines.

Consumers (current): handlers_match.go. Other handler families will migrate as later slices touch them; *Hub satisfies this interface by structural match.

type CompetitionStore

type CompetitionStore interface {
	// LoadCompetition returns the competition record, or (nil, nil) when
	// no record exists for this ID. Mirrors state.Store.LoadCompetition.
	LoadCompetition(id string) (*state.Competition, error)
}

CompetitionStore is the consumer-boundary view of state.Store used by handler families that need to read/write competition-level data (config.md, pools, brackets, schedule). Methods are added here only as real handler call sites need them — see server.go for the constructor that wires `*state.Store` through this interface.

Consumers (current): handlers_match.go (the score endpoint, after T017 migration). Other handler families continue to hold the concrete `*state.Store` until later slices migrate them; the concrete type remains a valid implementation of this interface so the migration is drop-in.

type CompetitionTransactor

type CompetitionTransactor interface {
	// WithTransaction runs fn under the per-competition write lock for
	// compID. fn receives a state.StoreTx handle whose methods skip
	// re-locking; the lock is released on return (success or error).
	// Mirrors state.Store.WithTransaction.
	WithTransaction(compID string, fn func(tx state.StoreTx) error) error
}

CompetitionTransactor is the consumer-boundary view of state.Store.WithTransaction used by handler families that need to commit multiple file mutations under one per-comp lock acquire (T155/T156). Kept as a single-method interface deliberately — handlers that ALSO need read access compose this with the CompetitionStore / TeamLineupStore / etc. interfaces at the registration site, same pattern the other handler families use.

Consumers (current): handlers_lineup.go (the PUT, T156). The score and decision handlers are queued for migration once the engine methods learn tx-aware variants; until then they hold the concrete *engine.Engine which internally locks per-comp.

type CompetitorStatusRequest

type CompetitorStatusRequest struct {
	PlayerID string `json:"playerId"`
	Eligible bool   `json:"eligible"`
	Reason   string `json:"reason,omitempty"`
	MatchID  string `json:"matchId,omitempty"`
}

CompetitorStatusRequest is the body for POST /competitor-status. Mirrors domain.CompetitorStatus on the wire.

func (*CompetitorStatusRequest) Validate

func (r *CompetitorStatusRequest) Validate() error

Validate enforces persisted-string caps on the request shape. The domain.CompetitorStatus.Validate path covers presence (PlayerID, Reason on ineligible) but not length — this fills that gap so a 1MB reason can't bloat competitor_status.yaml.

type CompetitorStatusStore

type CompetitorStatusStore interface {
	LoadCompetitorStatus(compID string) (map[string]domain.CompetitorStatus, error)
	SetCompetitorStatus(compID string, status domain.CompetitorStatus) error
}

CompetitorStatusStore is the consumer-boundary view of state.Store used by handlers_eligibility.go. Mirrors the LoadCompetitorStatus / SetCompetitorStatus methods on *state.Store.

type DaihyosenEngine

type DaihyosenEngine interface {
	AddDaihyosen(compID, matchID string, sideA, sideB engine.TeamSummary, isPool bool, sideAEligible, sideBEligible int) (*state.SubMatchResult, error)
	RecordMatchResultWithIneligibility(compID, matchID string, result *state.MatchResult) (*domain.CompetitorStatus, error)
	MaybeAutoCompletePools(compID string) (engine.AutoCompleteOutcome, error)
}

DaihyosenEngine is the consumer-boundary view of *engine.Engine used by the daihyosen handler. Mirrors engine.Engine.AddDaihyosen + RecordMatchResultWithIneligibility + MaybeAutoCompletePools.

Defined as a named local interface (rather than reusing ScoringEngine) because AddDaihyosen is not on the existing ScoringEngine interface, and broadening that surface for one new endpoint would expose the method to every other handler family.

type DaihyosenStore

type DaihyosenStore interface {
	LoadPoolMatches(compID string) ([]state.MatchResult, error)
	LoadBracket(compID string) (*state.Bracket, error)
	LoadCompetition(compID string) (*state.Competition, error)
	LoadParticipants(compID string, withZekkenName bool) ([]domain.Player, error)
	CompetitorStatusStore
}

DaihyosenStore is the consumer-boundary view of *state.Store used by the daihyosen handler. The handler needs to (a) look up the target match in either the pool-match store or the bracket store so it can derive TeamSummary values from SubResults, (b) load participants so it can map roster names to player IDs for eligibility counting, and (c) load competitor-status to count eligible competitors per side.

type DecisionRequest

type DecisionRequest struct {
	Decision       string               `json:"decision"`
	DecisionBy     string               `json:"decisionBy"`
	DecisionReason string               `json:"decisionReason,omitempty"`
	Encho          *state.EnchoMetadata `json:"encho,omitempty"`
	Force          bool                 `json:"force,omitempty"`
}

DecisionRequest is the body shape for `POST /api/competitions/:cid/matches/:mid/decision`.

Per contracts/match-decisions.md §POST /decision the operator supplies only the decision-type metadata; the server auto-fills the scoreline and Winner based on decisionBy + encho.

Force bypasses the decision-lock check (T103/CHK024) that prevents overwriting a prior kiken/fusenpai when a subsequent match for either participant has already started. The admin UI sets it after the operator confirms the override.

func (*DecisionRequest) Validate

func (r *DecisionRequest) Validate() error

Validate enforces request-shape invariants on a decision payload before the engine touches it.

  • decision MUST be one of kiken-voluntary/kiken-injury/fusenpai/fusensho/daihyosen (legacy "kiken" is remapped to "kiken-voluntary").
  • decisionBy is required and MUST be "shiro" or "aka".
  • decisionReason ≤ 200 chars (contract).

type EligibilityEngine

type EligibilityEngine interface {
	ReinstateCompetitor(compID, playerID string) (*domain.CompetitorStatus, error)
}

EligibilityEngine is the consumer-boundary view of engine.Engine used by the reinstate handler. Separated from ScoringEngine because reinstatement is an eligibility concern, not a scoring concern.

type EventType

type EventType string

EventType defines the kind of update being broadcast

const (
	EventMatchUpdated            EventType = "match_updated"
	EventCompetitionStarted      EventType = "competition_started"
	EventCompetitionCompleted    EventType = "competition_completed"
	EventTournamentUpdated       EventType = "tournament_updated"
	EventScheduleUpdated         EventType = "schedule_updated"
	EventCompetitorStatusUpdated EventType = "competitor_status_updated"
	EventParticipantsUpdated     EventType = "participants_updated"
	// EventPasswordReset fires when the admin password is rotated. Three
	// broadcast sites (file mode only; never emitted in locked mode):
	//   - POST /api/tournament/reset — the public recovery endpoint for
	//     a forgotten password. Payload: {originatorId} so the submitting
	//     tab can suppress its own broadcast and avoid clearing the
	//     credential it just wrote.
	//   - PUT /api/tournament — when the PUT body contains a non-empty
	//     password that differs from the stored one. Payload: {} (all
	//     sessions, including the editing tab, should re-authenticate).
	//   - POST /api/tournament — when a bootstrap POST overwrites an
	//     existing tournament with a different password. Payload: {}.
	// Consumers in admin mode (app.jsx) clear their localStorage credential
	// and re-show the AuthModal so a logged-in operator notices immediately
	// instead of waiting for their next write to fail with 401. Viewers
	// ignore the event — their flow doesn't depend on the admin password.
	EventPasswordReset EventType = "password_reset"
	EventAnnouncement  EventType = "announcement"
)
const EventSwissRoundGenerated EventType = "swiss_round_generated"

EventSwissRoundGenerated is the SSE event broadcast when a new Swiss round's matches are generated. Clients refresh their match list and competition view on this event (the bumped swissCurrentRound is included in the payload so callers don't need a separate GET).

FR-050d.

type Hub

type Hub struct {
	HistorySize int

	// MaxClients caps concurrent SSE subscribers (mp-663 Phase 4).
	// Subscribe returns nil when the cap is reached; HandleEvents
	// converts that into HTTP 503 so the client knows to back off. A
	// non-positive value disables the cap (treat as unbounded — useful
	// for tests).
	MaxClients int
	// contains filtered or unexported fields
}

Hub manages active SSE connections and broadcasts events.

The hub also owns:

  • a monotonic `seq` counter stamped on every broadcast envelope
  • a fixed-size ring buffer of recent envelopes for replay-on-reconnect

Both are protected by the same `mu` mutex used for the client set so the order of "stamp seq" / "append to history" / "fan out to clients" is observed atomically — without that, a concurrent reconnect could read a partial history slice and replay events out of order.

func NewHub

func NewHub() *Hub

func NewHubWithHistory

func NewHubWithHistory(historySize int) *Hub

NewHubWithHistory constructs a Hub with a custom ring buffer capacity. Used by tests that need predictable eviction behaviour without generating 100+ events. MaxClients stays at the default.

func NewHubWithLimits

func NewHubWithLimits(historySize, maxClients int) *Hub

NewHubWithLimits constructs a Hub with explicit history-size and subscriber-cap.

historySize: non-positive falls back to DefaultHistorySize (the ring buffer needs a non-zero capacity to function).

maxClients: passed through as-is. A POSITIVE value caps concurrent subscribers at that count (Subscribe returns nil over the cap). A non-positive value (zero or negative) DISABLES the cap entirely — useful for tests that need to exceed DefaultMaxSSEClients without constructing thousands of stub clients to prove the cap fires. It is deliberately not coerced to the default so callers can opt out.

func NewRouter

func NewRouter(store *state.Store, eng *engine.Engine, res *resources.Resources, verifier PasswordVerifier) (*gin.Engine, *Hub)

NewRouter wires the mobile-app gin engine. The returned *gin.Engine is the HTTP handler; the returned *Hub is exposed so the caller (cmd/mobile_app.go) can call Hub.Close() from a graceful-shutdown hook — without that, http.Server.Shutdown would block forever on the long-lived SSE goroutines.

func NewRouterWithHub

func NewRouterWithHub(store *state.Store, eng *engine.Engine, res *resources.Resources, verifier PasswordVerifier, hub *Hub) (*gin.Engine, *Hub)

NewRouterWithHub is the testable / configurable variant — pass a pre-built Hub (e.g. one with NewHubWithLimits) instead of constructing the default. cmd/mobile_app.go uses this to apply the SSE_MAX_CLIENTS override; tests use it to inject a small-capacity hub.

func (*Hub) Broadcast

func (h *Hub) Broadcast(eventType EventType, data any)

Broadcast sends an event to all subscribed clients.

The envelope is stamped with a strictly-monotonic seq counter (T215) and appended to the ring buffer (T216) under the same write lock as the client fan-out. This means concurrent Broadcasts from different goroutines see strictly increasing seqs in the order their lock-acquire sequenced — there's no "stamp before lock, lock for fan-out" window that would let two seqs land in history in reverse order.

func (*Hub) Close

func (h *Hub) Close()

Close marks the hub as shutting down and closes every subscriber channel. The per-connection streaming goroutine in HandleEvents observes the channel close (the `case msg, ok := <-ch; if !ok` branch) and returns, unblocking http.Server.Shutdown's wait loop.

Idempotent — safe to call twice. After Close, Subscribe returns nil for all new subscribers and Broadcast becomes a no-op observable to clients (no live channels remain).

func (*Hub) HandleEvents

func (h *Hub) HandleEvents() gin.HandlerFunc

HandleEvents returns a Gin handler for the SSE endpoint.

On connect, the handler honours the `Last-Event-ID` header (set automatically by `EventSource` from the last `id:` line the browser saw) and replays any retained events with seq > Last-Event-ID before streaming live events. The replay walks the hub's ring buffer (capacity HistorySize) — if the requested Last-Event-ID is older than the oldest retained entry, the client gets whatever is still in the buffer plus a warning logged server-side.

The handler also emits an SSE `id: <seq>` line for every event so the browser's auto-reconnect carries the right Last-Event-ID without any JS work.

func (*Hub) Subscribe

func (h *Hub) Subscribe() chan string

Subscribe adds a new client channel to the hub. Returns nil when:

  • the hub has been Close()d (graceful shutdown in progress)
  • the subscriber count has reached MaxClients

HandleEvents converts a nil return into HTTP 503 so the SSE client knows to back off and retry rather than hanging on a stuck connection.

func (*Hub) Unsubscribe

func (h *Hub) Unsubscribe(ch chan string)

Unsubscribe removes a client channel

type ImportManifest

type ImportManifest struct {
	Competitions []ImportManifestComp `yaml:"competitions"`
}

type ImportManifestComp

type ImportManifestComp struct {
	ID             string   `yaml:"id"`
	Name           string   `yaml:"name"`
	Kind           string   `yaml:"kind"`   // "individual" or "team"
	Format         string   `yaml:"format"` // "pools" or "playoffs" or "swiss"
	Courts         []string `yaml:"courts"`
	PoolSize       int      `yaml:"pool_size"`
	PoolSizeMode   string   `yaml:"pool_size_mode"` // "max" or "min"
	PoolWinners    int      `yaml:"pool_winners"`
	RoundRobin     bool     `yaml:"round_robin"`
	NumberPrefix   string   `yaml:"number_prefix"`
	WithZekkenName bool     `yaml:"with_zekken_name"`
	TeamSize       int      `yaml:"team_size"`
	Mirror         bool     `yaml:"mirror"`
	StartTime      string   `yaml:"start_time"`
	Date           string   `yaml:"date"`
	// SwissRounds — number of Swiss rounds to play when format=swiss
	// (FR-050a). Ignored for other formats.
	SwissRounds int `yaml:"swiss_rounds"`
	// File names relative to the uploaded set
	Participants string `yaml:"participants"`
	Seeds        string `yaml:"seeds"`
}

ImportManifestComp describes one competition entry in manifest.yaml.

type ImportResult

type ImportResult struct {
	ID               string `json:"id"`
	Name             string `json:"name"`
	ParticipantCount int    `json:"participantCount"`
	SeedCount        int    `json:"seedCount"`
	Error            string `json:"error,omitempty"`
}

type LineupRequest

type LineupRequest struct {
	Positions map[domain.Position]string `json:"positions"`
}

LineupRequest is the body for PUT /lineups/:round. We accept the positions map as the only required field — teamID/round/compID are pinned by the URL path, and LockedAt is server-managed (the engine stamps it when the round's first match goes live).

type PasswordVerifier

type PasswordVerifier interface {
	// Verify reports whether the supplied X-Tournament-Password header
	// value authenticates as admin. Implementations decide whether to
	// consult the stored tournament record or an out-of-band secret.
	Verify(presented string) (bool, error)

	// Mode returns "file" or "locked" so the public GET /api/auth-config
	// endpoint can surface the active mode to the SPA. The SPA uses this
	// to hide the "Forgot password?" link and the reset form when locked.
	Mode() string

	// ResetEnabled reports whether the POST /api/tournament/reset endpoint
	// should accept resets. file mode = true; locked mode = false.
	ResetEnabled() bool

	// AllowsFileBootstrap reports whether the middleware should let an
	// UNAUTHENTICATED POST/PUT to /api/tournament through when no
	// tournament has been bootstrapped yet.
	//
	//   - file mode: true. The operator picks the admin password during
	//     CreateTournament; before that, there's nothing to authenticate
	//     against. This is the historical UX.
	//   - locked mode: false. The env-var bcrypt hash IS the admin
	//     credential from the very first request; allowing anonymous
	//     bootstrap on an internet-exposed fresh deployment would let
	//     any network client race-claim the initial tournament record
	//     with garbage data before the operator finishes deploying.
	//     The SPA's CreateTournament form sends the env-var password
	//     in the X-Tournament-Password header when authConfig.mode ==
	//     "locked"; the middleware accepts the bootstrap if that header
	//     verifies.
	AllowsFileBootstrap() bool

	// EnforceEmptyStoredGuard reports whether the middleware should
	// 403 when the stored tournament has an empty Password field. This
	// is defense-in-depth against the F4 sentinel-into-auth scenario in
	// file mode (an empty stored password would let `password != t.Password`
	// pass vacuously for an empty header). In locked mode the stored
	// password is irrelevant, so the guard would 403 every request — it
	// must be disabled.
	EnforceEmptyStoredGuard() bool

	// RedactStoredPassword reports whether the on-disk Tournament.Password
	// field is non-authoritative for authentication and must therefore be
	// stripped from API responses AND ignored on writes. True for any
	// verifier whose authentication does not consult `tournament.md` —
	// today that's the bcrypt locked verifier; tomorrow it might be an
	// OIDC or LDAP variant. Centralizing the test here means handlers
	// don't grow scattered `verifier.Mode() == "locked"` string compares
	// that would silently break when a third mode is added.
	RedactStoredPassword() bool
}

PasswordVerifier abstracts the admin-auth source so the same middleware can serve both:

  • "file" mode: the password is stored plaintext in tournament.md and verified by exact-string match. This is the default and matches the pre-existing behavior.
  • "locked" mode: the password is supplied out-of-band as a bcrypt hash in the TOURNAMENT_PASSWORD_HASH environment variable and verified via bcrypt.CompareHashAndPassword. The stored tournament.md password is irrelevant in this mode.

Locked mode is selected via the --lock-password CLI flag (or LOCK_PASSWORD=true env var) on the mobile-app command and is the recommended setting for any internet-exposed deployment.

func NewBcryptVerifier

func NewBcryptVerifier(hash string) (PasswordVerifier, error)

NewBcryptVerifier validates `hash` as a bcrypt-encoded password hash and returns a verifier that compares incoming X-Tournament-Password values against it. The validation runs `bcrypt.Cost([]byte(hash))` which parses the hash header (algorithm + cost prefix) without leaking any timing oracle on the secret itself — malformed hashes are caught at startup so the operator gets a clear error rather than a 401-on-every- request runtime puzzle.

An empty hash is rejected with a distinct error so the calling CLI can distinguish "operator forgot to set TOURNAMENT_PASSWORD_HASH" from "operator set a malformed hash."

func NewFileVerifier

func NewFileVerifier(store *state.Store) PasswordVerifier

NewFileVerifier constructs the default verifier. Returns the PasswordVerifier interface so callers cannot accidentally reach into the concrete type — the interface is the canonical contract, and any future verifier (LDAP, OIDC) can be wired in without breaking signatures.

type SSEEvent

type SSEEvent struct {
	Type EventType `json:"type"`
	Data any       `json:"data"`
	Seq  int64     `json:"seq"`
}

SSEEvent represents the payload sent to clients.

Seq (T215, A2 closure) is a strictly monotonic counter stamped by the hub on every Broadcast. Consumers use it for two things:

  1. Gap detection — if the JS receiver sees seq jump from N to N+2, it missed N+1 (network reorder/loss is impossible on a single SSE connection, but a reconnect can leave a gap between the last live event before the disconnect and the first one after the reconnect) and triggers a full refetch.
  2. Replay on reconnect — `EventSource` automatically resends the last `id:` it saw via `Last-Event-ID`; the hub walks its ring buffer to re-emit events with seq > Last-Event-ID before resuming live streaming.

Wire format: `data: {"type":"match_updated","data":{…},"seq":12345}` plus an SSE `id: 12345` line so the browser sets `Last-Event-ID` automatically on reconnect.

Existing JS that ignores `seq` continues to work — the field is purely additive.

type ScoreRequest

type ScoreRequest state.MatchResult

ScoreRequest is the body shape for `PUT /api/competitions/:id/matches/:mid/score`. It is the minimal example implementation of the Validator pattern (T015).

Defined as a named type whose underlying type is state.MatchResult so the JSON shape is identical to the pre-Slice-0 endpoint (clients send MatchResult fields at the top level) — no client-side change. The named type lets us hang Validate() off it without polluting state (which is a pure-data package).

As later slices add decision-type / encho fields (see Slice 3 FR-031, T077), the matching Validate() rules land here.

func (*ScoreRequest) AsMatchResult

func (r *ScoreRequest) AsMatchResult() *state.MatchResult

AsMatchResult returns the underlying state.MatchResult value so the score handler can forward it to the engine. The conversion is a zero-cost type conversion (identical underlying layout).

func (*ScoreRequest) Validate

func (r *ScoreRequest) Validate() error

Validate enforces request-shape invariants on a score payload before the engine touches it. Rules deliberately match the existing engine guards so behaviour is unchanged:

  • Status, when set, must be one of the documented MatchStatus values.
  • Winner, when set alongside both sides, must name one of the sides (a Winner that names neither side would silently miscount in standings).
  • Decision (T077, FR-031, contracts/match-decisions.md): value must be one of fought/hikiwake/kiken/fusenpai/fusensho/ daihyosen/kachinuki-exhaustion (or empty). kiken/fusenpai require decisionBy and a winning-side scoreline (2-0 in regulation, 1-0 in encho for kiken). fusensho is only valid on a per-bout SubResult, not on a top-level score request.

type ScoringEngine

type ScoringEngine interface {
	// RecordMatchResult applies the given result to the pool match (or
	// falls through to the bracket match) for compID/matchID. Mirrors
	// engine.Engine.RecordMatchResult.
	RecordMatchResult(compID string, matchID string, result *state.MatchResult) error
	// RecordMatchResultWithIneligibility is the variant used by the
	// score handler when the request may have recorded a kiken or
	// fusenpai decision. The returned CompetitorStatus is non-nil only
	// when a new ineligibility was persisted — the handler uses that to
	// drive the competitor-status-updated SSE broadcast (T085/T092).
	RecordMatchResultWithIneligibility(compID string, matchID string, result *state.MatchResult) (*domain.CompetitorStatus, error)
	// RecordMatchResultWithIneligibilityTx is the tx-aware twin used by
	// the score handler under WithTransaction (T156). Same return shape
	// as RecordMatchResultWithIneligibility; calls flow through the
	// supplied StoreTx so the match-write + ineligibility-write +
	// lineup-freeze all commit under one lock acquire.
	RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, matchID string, result *state.MatchResult) (*domain.CompetitorStatus, error)
	// StartMatchTx is the FR-035 eligibility gate for the
	// scheduled → running transition. Returns
	// *engine.IneligibleCompetitorError when a participant is marked
	// ineligible by a *different* match. The undo path (re-scoring a
	// match that itself created the ineligibility) is permitted.
	// Score handler wraps fought/hikiwake submissions with this.
	StartMatchTx(tx state.StoreTx, compID, matchID string) error
	// RecordDecision auto-fills the scoreline + winner from the
	// decision/decisionBy/encho triple and persists the result. Used by
	// the dedicated POST /decision endpoint (T090). When the prior
	// result on the match already carried a kiken/fusenpai decision the
	// engine enforces the downstream-match lock (T103/CHK024); force=true
	// bypasses the lock so an operator can confirm an override.
	RecordDecision(compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool) (*state.MatchResult, *domain.CompetitorStatus, error)
	// RecordDecisionTx is the tx-aware twin of RecordDecision used by
	// the decision handler under WithTransaction (T156). Same contract
	// as RecordDecision; calls flow through the supplied StoreTx.
	RecordDecisionTx(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool) (*state.MatchResult, *domain.CompetitorStatus, error)
	// MaybeAutoCompletePools transitions the competition's status to
	// "complete" when every pool match is done, or injects supplementary
	// ippon-shobu tiebreaker matches when ties are detected. Mirrors
	// engine.Engine.MaybeAutoCompletePools.
	MaybeAutoCompletePools(compID string) (engine.AutoCompleteOutcome, error)
	// UpdateMatchCourt reassigns a match to a different court. Mirrors
	// engine.Engine.UpdateMatchCourt.
	UpdateMatchCourt(compID string, matchID string, newCourt string) error
	// OverrideBracketWinner sets the winner of a bracket match by
	// participant name (used by the admin "manual winner" flow). Mirrors
	// engine.Engine.OverrideBracketWinner.
	OverrideBracketWinner(compID string, matchID string, winnerName string) error
	// UpdateMatchTime updates a match's scheduledAt. Mirrors
	// engine.Engine.UpdateMatchTime.
	UpdateMatchTime(compID string, matchID string, scheduledAt string) error
	// MaybeAdvanceKachinuki runs the post-score advancement for a
	// kachinuki ("winner-stays-on") team match. No-op for non-kachinuki
	// competitions. Mirrors engine.Engine.MaybeAdvanceKachinuki.
	// FR-044, T135.
	MaybeAdvanceKachinuki(compID, matchID string) (bool, error)
}

ScoringEngine is the consumer-boundary view of engine.Engine used by the match-score handler family. Pre-Slice-0 the handlers held a concrete `*engine.Engine`, which forced any handler test to spin up the full engine + store stack. The interface lets handler tests stub these calls independently.

Consumers (current): handlers_match.go, handlers_decision.go.

type TeamLineupStore

type TeamLineupStore interface {
	LoadTeamLineups(compID string) (map[string]domain.TeamLineup, error)
	SetTeamLineup(compID string, lineup domain.TeamLineup, teamSize int) error
	DeleteTeamLineup(compID, teamID string, round int) error
	LockTeamLineupsForRound(compID string, round int, lockedAt time.Time) error
}

TeamLineupStore is the consumer-boundary view of state.Store used by handlers_lineup.go (Slice 7.B / T127). Mirrors the LoadTeamLineups / SetTeamLineup / DeleteTeamLineup / LockTeamLineupsForRound methods on *state.Store.

The handler also needs the competition's TeamSize to drive TeamLineup.Validate, so it composes this interface with CompetitionStore at the registration site rather than promoting LoadCompetition into this minimal store interface — same pattern the other handler families use.

type ValidationError

type ValidationError struct {
	// Field is the JSON field name that failed validation, or "" when
	// the failure spans multiple fields.
	Field string
	// Message is the operator-facing reason, designed to be returned
	// verbatim in the HTTP 400 response body.
	Message string
}

ValidationError is a typed error returned by Validate() so handlers can distinguish shape errors (400) from store / engine errors (500). Handlers map ValidationError directly to a 400 with the Message body.

func (*ValidationError) Error

func (e *ValidationError) Error() string

type Validator

type Validator interface {
	Validate() error
}

Validator is the contract every request body should satisfy after JSON binding. Validate() returns nil when the body is well-formed against its own shape rules; ValidationError when it isn't.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL