mobileapp

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Jul 13, 2026 License: MPL-2.0 Imports: 41 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_league_tiebreak.go owns the four operator endpoints for league tie-breaker management (Phase 3b, mp-8rc9):

GET  /api/competitions/:cid/league-tiebreak/candidates  (public, no auth)
POST /api/competitions/:cid/league-tiebreak             (admin-gated)
DELETE /api/competitions/:cid/league-tiebreak           (admin-gated)
POST /api/competitions/:cid/league-tiebreak/finalize    (admin-gated)

The GET read is public (registered via RegisterPublicLeagueTiebreakHandlers on the unauthenticated api group, mirroring RegisterPublicSwissHandlers). The three write/mutate operations require X-Tournament-Password and are registered via RegisterLeagueTiebreakHandlers on the adminSmallBody group.

The design mirrors handlers_daihyosen.go: narrow consumer-boundary interfaces, request body caps enforced by the adminSmallBody group in server.go, and errors surfaced as typed engine errors (NotFoundError → 404, ValidationError → 400, conflict guards → 409).

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. Lineups are always editable, including while a match is running or completed (mp-q722).

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 (
	// AnnouncementMaxBodyBytes caps POST /api/tournament/announce.
	// Worst-case JSON encoding: 200-char message × 6 bytes/escape + keys
	// ≈ 1242 bytes; 4096 gives ≈3× headroom. Applied via MaxBodyBytes
	// before AuthMiddleware on the /api announce route group in server.go.
	AnnouncementMaxBodyBytes int64 = 4096

	// ResetMaxBodyBytes caps POST /api/tournament/reset.
	// Worst-case JSON: password 256 chars × 6 bytes/escape + originatorId
	// 128 × 6 + overhead ≈ 2344 bytes; 4096 gives ≈1.7× headroom. Applied
	// inside the handler (not as group middleware) to preserve the locked-mode
	// 404 response before any body is read, a per-group cap would reveal the
	// route's existence via 413 vs 404 on locked deployments.
	ResetMaxBodyBytes int64 = 4096

	// 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

	// SponsorMaxBodyBytes caps POST /api/sponsors (multipart logo upload).
	// The sponsor logo file itself is capped at 1 MB by in-handler check;
	// this envelope cap (2 MB) gives generous headroom for the multipart
	// boundary, form-field overhead, and the optional link/name fields
	// without letting a client stream gigabytes through the parser.
	SponsorMaxBodyBytes int64 = 2 << 20 // 2 MB

	// BrandingMaxBodyBytes caps POST /api/branding/logo (tournament logo
	// upload, mp-scf). Same rationale and size as SponsorMaxBodyBytes.
	BrandingMaxBodyBytes int64 = 2 << 20 // 2 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 (
	DefaultPerIPRate  = 100 // requests per second per IP
	DefaultPerIPBurst = 200 // burst allowance per IP

)

Default per-IP rate limit constants. These are intentionally generous, no legitimate tournament user (viewer refreshing, admin scoring rapidly) comes close to 100 req/s from a single device. The per-IP layer exists to prevent a single misbehaving client from exhausting the global budget and starving everyone else.

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.

	// mp-ef3: public tournament info field caps.
	MaxLenPublicURL       = 500 // mp-s1gl: externally-shareable base URL
	MaxLenVenueAddress    = 300
	MaxLenVenueMapURL     = 500
	MaxLenDisplayTime     = 8 // "HH:MM" or "HH:MM:SS"
	MaxLenWebsiteURL      = 500
	MaxLenAwardsNote      = 500
	MaxLenInfoNotes       = 2000
	MaxLenContactLabel    = 50
	MaxLenContactValue    = 200
	MaxTournamentContacts = 10

	// MaxTournamentDurationDays is the upper bound on Tournament.DurationDays.
	// 30 days covers the longest conceivable multi-day open tournament.
	MaxTournamentDurationDays = 30

	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
	// Operator audit free-text (correction note) shares the same human-readable
	// purpose and bound as DecisionReason.
	MaxLenCorrectionReason  = MaxLenDecisionReason
	MaxLenEligibilityReason = 200
	MaxLenEntityID          = 64 // matches state.ValidateCompetitionID cap
	// MaxLenRevSession caps ScoreRequest.RevSession (an opaque session id, e.g.
	// a 36-char UUID; 64 leaves headroom).
	MaxLenRevSession = 64

	MaxLenSeedAssignmentName = 100

	// MaxLenMatchID caps the byte length of the "mid" path parameter accepted
	// by the score endpoint. Match IDs legitimately contain spaces (e.g.
	// "Pool A-1"), so a charset regex is inappropriate, a length cap is the
	// right defense-in-depth guard against abusive keys growing runningRevStore
	// unbounded. 128 bytes covers any realistic match ID.
	MaxLenMatchID = 128

	// MaxBulkCheckInIDs is the upper bound on the participantIds array
	// accepted by POST /competitions/:id/participants/checkin-bulk. A
	// single per-comp write lock is held for the duration; 1000 is a
	// practical ceiling for tournament rosters (no real competition has
	// exceeded ~200).
	MaxBulkCheckInIDs = 1000

	// MaxFightingSpiritAwards is the upper bound on the number of fighting
	// spirit awards a competition may carry. 20 is a generous cap for
	// the typical ceremony (usually 1–3 honourees).
	MaxFightingSpiritAwards = 20
)

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 AdminPasswordHashEnv = "TOURNAMENT_ADMIN_PASSWORD_HASH"

AdminPasswordHashEnv is the env var holding the bcrypt hash of the elevated (destructive-ops) password in locked mode (spec 004 / mp-e21). It is the elevated-credential analogue of TOURNAMENT_PASSWORD_HASH.

View Source
const BrandingMaxFileBytes int64 = 1 << 20 // 1 MB

BrandingMaxFileBytes is the in-handler cap on the tournament branding logo file itself. Kept separate from SponsorMaxFileBytes so the two limits can evolve independently.

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 = 5000

DefaultMaxSSEClients caps concurrent /api/events subscribers per process. Each subscriber allocates one buffered channel (100-element string buffer) plus one streaming goroutine. Base struct/goroutine overhead per client:

  • Channel buffer: 100 string headers × 16 B (pointer + length) ≈ 1.6 KB
  • Channel struct overhead: ~100 B
  • Goroutine stack: 2–8 KB (starts small, grows on demand)
  • Base total: ~4–10 KB resident per client

Note: this is the base overhead only. Each queued (buffered) message also keeps its payload bytes alive on the heap until the client drains it. Under bursty broadcasts or slow/stalled clients a fully-backlogged channel could add up to ~100 × (payload size) of additional memory per client, e.g. a 2 KB JSON payload × 100 slots = ~200 KB worst-case. Stalled clients are dropped by the non-blocking send (select/default) before the buffer fills in practice, but the extra allocation is real during the drop window.

At 5000 active (draining) clients the base cost is ~20–50 MB, comfortably within the RAM budget of a single-core VPS or RPi-class hardware used for large-scale deployments (tens of teams, 1000+ live spectators).

Broadcast fan-out is O(N) in the subscriber count and serialises under h.mu.Lock(). At 5000 clients the lock window is bounded by the non-blocking channel send (select/default), so stalled clients are dropped rather than blocking the broadcaster.

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 in production. The cap is mp-663 Phase 4 mitigation for resource-exhaustion via unbounded subscriber maps. Raised from 1000 → 5000 by mp-9afd to support large-scale events (1000+ viewers). A real hardware load test (goroutine/fd/memory budget at 5000 clients) is still required before a large live deployment.

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.

View Source
const SponsorMaxFileBytes int64 = 1 << 20 // 1 MB

SponsorMaxFileBytes is the in-handler cap on the sponsor logo file itself (the multipart `file` field), distinct from the envelope cap above. See mp-c38 plan.

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 IsSelfRunReportableDecision added in v0.17.0

func IsSelfRunReportableDecision(decision string, decidedByHantei *bool) bool

IsSelfRunReportableDecision reports whether the given decision value is permitted for participant self-reporting in self-run tournaments (i.e. when no valid admin password is present on the request).

Allowed at the top level: "" (none), "fought", "hikiwake". These are factual observations a participant can make without referee authority. fusensho is only valid on sub-results (ScoreRequest.Validate rejects it at the top level), so it's not listed here.

Rejected: "kiken-voluntary", "kiken-injury", "fusenpai", "daihyosen", "kachinuki-exhaustion", "fusensho", referee/operator rulings with eligibility side-effects or official designation requirements. Also rejected when decidedByHantei is explicitly true (judges' panel decision).

func IsSelfRunReportableSubDecision added in v0.17.0

func IsSelfRunReportableSubDecision(decision string, decidedByHantei bool, position int) bool

IsSelfRunReportableSubDecision validates a sub-bout decision for self-run anonymous callers. Allowed: "" (none), "fought", "hikiwake", "fusensho" (per-bout forfeiture is a factual observation). Rejected: kiken variants, fusenpai, daihyosen, kachinuki-exhaustion, decidedByHantei=true. Also rejects position == -1 (daihyosen representative bout placeholder).

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 NewRouter

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

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. The returned *APIRateLimiter should also be closed on shutdown to stop the per-IP cleanup goroutine.

func NewRouterWithHub

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

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. scheduleEnabled is sourced from ENABLE_TOURNAMENT_SCHEDULE (mp-fwce) and forwarded to RegisterAuthConfigHandlers.

func RegisterAdminPasswordHandler added in v0.17.0

func RegisterAdminPasswordHandler(r *gin.RouterGroup, store *state.Store, ev ElevatedVerifier)

RegisterAdminPasswordHandler wires PUT /api/auth/admin-password, the only way to set or rotate the file-mode elevated password (spec 004 §6a). It is registered inside the admin group, so AuthMiddleware has already verified the MAIN password before this runs.

Gating:

  • locked mode → 404. The elevated credential is the env-var bcrypt hash (TOURNAMENT_ADMIN_PASSWORD_HASH); it is not settable via the API. The 404 (not 405/403) matches the "route doesn't exist in this mode" shape used by the password-reset endpoint.
  • file mode, no admin password set yet → trust-on-first-use: the main password (already verified) authorizes the initial set.
  • file mode, admin password already set → rotation requires the correct CurrentPassword (verified via the ElevatedVerifier) in addition to the main password.

func RegisterAnnouncementHandlers

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

func RegisterAuthConfigHandlers

func RegisterAuthConfigHandlers(r *gin.RouterGroup, verifier PasswordVerifier, elevated ElevatedVerifier, scheduleEnabled bool)

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.

scheduleEnabled is sourced from ENABLE_TOURNAMENT_SCHEDULE in cmd/mobile_app.go and threaded here so the env var is read once at startup rather than on every request.

func RegisterBrandingHandlers added in v0.17.0

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

RegisterBrandingHandlers wires admin-gated mutation endpoints. The caller must use a group whose body cap is at least BrandingMaxBodyBytes (2 MB).

hub broadcasts EventTournamentUpdated after a successful logo upload/delete so open viewer and display surfaces re-fetch and re-apply branding live, matching PUT /tournament (same live-update gap as sponsors, mp-scf).

func RegisterCompetitionHandlers

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

func RegisterDaihyosenHandlers

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

RegisterDaihyosenHandlers wires the POST and DELETE /daihyosen endpoints. 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 court-scoped surfaces:

  • GET /api/viewer/court/:court/current, a one-shot polled view of the currently-running match on a court, for non-browser streaming integrations (vMix, OBS plugins, scoreboards).
  • GET /api/viewer/court/:court/matches, the operator console's court feed: every competition with a real match physically placed on the court, each with its full {config, poolMatches, bracket} payload.

Browser clients otherwise use SSE (/api/events); these polled surfaces exist for clients that cannot subscribe (and to right-size the operator console).

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 RegisterExportResultsHandlers added in v1.0.0

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

RegisterExportResultsHandlers wires the admin-gated results-export endpoint.

Route: GET /api/competitions/:id/export-results

This streams a RESULTS-populated workbook (played scores, standings, winners, and decision suffixes written as literal values) built by internal/export. It is deliberately DISTINCT from the sibling GET .../export, which renders a blank formula template that feeds the PDF pipeline; registering the same path twice would panic Gin at startup. Read-only: it loads state and writes nothing, so it is safe alongside the stateful admin APIs.

func RegisterImportHandlers

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

func RegisterLeagueTiebreakHandlers added in v0.17.0

func RegisterLeagueTiebreakHandlers(r *gin.RouterGroup, eng LeagueTiebreakEngine, store LeagueTiebreakStore, hub Broadcaster)

RegisterLeagueTiebreakHandlers wires the three admin-gated league-tiebreak mutation endpoints. Callers pass *engine.Engine and *state.Store which satisfy the local interfaces by structural match.

func RegisterLineupHandlers

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

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 `tx CompetitionTransactor` parameter is the T156 hook. The PUT body wraps its three store calls; load comp (for teamSize), set lineup, reload lineup (for the response), all in one WithTransaction so they all commit under a single per-comp lock acquire. `hub Broadcaster` receives an EventLineupUpdated after each successful write so SSE clients can re-fetch lineup data. `*state.Store` satisfies the first three interfaces (TeamLineupStore + CompetitionStore + CompetitionTransactor); the SSE hub satisfies Broadcaster and is wired separately in production.

func RegisterMatchHandlers

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

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, eng *engine.Engine, hub Broadcaster, elevated ElevatedVerifier)

func RegisterPrintHandlers added in v0.17.0

func RegisterPrintHandlers(r *gin.RouterGroup, eng *engine.Engine)

RegisterPrintHandlers wires the admin-gated PDF export endpoint under r. Route: POST /api/print/:type

Valid: type values are the Type fields of pdf.Groups (e.g. "registration", "names", "tags", "pools-trees", "full-bracket") plus the meta-selector "all". The set is derived from pdf.Groups at call time so it never drifts.

The handler is synchronous, PDF generation via LibreOffice takes 30–60 s for a typical tournament. That is acceptable for an admin-initiated, one-at-a-time operation. Concurrency is bounded by the package-level sofficeMu mutex in internal/pdf, which serialises every soffice invocation (pdf.Converter.ConvertToPDF); no additional queue is needed here.

func RegisterPublicAnnouncementHandlers

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

func RegisterPublicBrandingHandlers added in v0.17.0

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

RegisterPublicBrandingHandlers wires the unauthenticated GET and HEAD routes that serve the tournament logo bytes. When no custom logo is configured, GET redirects to the bundled default (/logo.jpeg) so image consumers never see a 404, while HEAD returns 404 so BrandingManager can probe whether a custom logo is set. HEAD is registered explicitly because Gin does not auto-route HEAD to GET.

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 RegisterPublicLeagueHandlers added in v1.0.0

func RegisterPublicLeagueHandlers(r *gin.RouterGroup, eng *engine.Engine)

RegisterPublicLeagueHandlers registers the public league standings read.

Leagues are distinct from pools: this dedicated endpoint feeds the league standings UI so it never consumes the pool standings path (the mislabelled "pools" tab). Mirrors RegisterPublicSwissHandlers (public read, no auth).

GET /competitions/:id/league/standings

func RegisterPublicLeagueTiebreakHandlers added in v0.17.0

func RegisterPublicLeagueTiebreakHandlers(r *gin.RouterGroup, eng LeagueTiebreakEngine, store LeagueTiebreakStore)

RegisterPublicLeagueTiebreakHandlers wires the unauthenticated league-tiebreak read endpoint on the public api group.

GET /competitions/:id/league-tiebreak/candidates

No Broadcaster is needed, this is a pure read. Callers pass *engine.Engine and *state.Store which satisfy the local interfaces by structural match.

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), all run 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 RegisterPublicRegistrationHandlers added in v0.17.0

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

RegisterPublicRegistrationHandlers registers public (no-auth) self-registration endpoints on the given router group (mp-e5j). These routes are only functional in self-run tournaments; they return 404 for officiated tournaments (defense-in-depth).

Routes:

GET  /api/register/competitions/:id, competition metadata for the registration form
POST /api/register/competitions/:id, register a new participant (tag="registered")

func RegisterPublicSponsorHandlers added in v0.17.0

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

RegisterPublicSponsorHandlers wires the unauthenticated GET route that serves sponsor logo bytes. Public because the logos render on viewer and TV/lobby surfaces that have no admin credentials.

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 RegisterSponsorHandlers added in v0.17.0

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

RegisterSponsorHandlers wires admin-gated mutation endpoints onto the supplied admin router group (which already carries body-cap + auth middleware). The caller must use a group whose body cap is at least SponsorMaxBodyBytes (2 MB), the in-handler file size check at SponsorMaxFileBytes still applies separately.

hub is used to broadcast EventTournamentUpdated after a successful upload/delete so that already-open viewer and display (TV/lobby) surfaces re-fetch the tournament and show the sponsor change live, matching the behaviour of PUT /tournament. Without this, a sponsor uploaded on the admin device stays invisible on a display wall until a manual page reload (mp-c38 gap surfaced during UAT).

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 RegisterTimeHandlers added in v1.0.0

func RegisterTimeHandlers(r *gin.RouterGroup)

RegisterTimeHandlers wires GET /api/time: a stateless, unauthenticated endpoint returning the server's current wall-clock time in unix milliseconds.

Clients (notably shiaijo operator tablets) call it on connect to learn the server-clock offset, then stamp every score/override write in SERVER-RELATIVE time. That lets the field-level reconciliation (mp-y3nk) compare change timestamps across devices by ONE clock, so an offline court reconnecting later does not spuriously win purely because its tablet clock is fast. The endpoint reads no state and holds no auth requirement, matching the schedule-estimator contract, so it serves both `make run` and `make run-mobile` frontends.

func RegisterTournamentHandlers

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

func RegisterVersionHandlers added in v0.17.0

func RegisterVersionHandlers(r *gin.RouterGroup)

RegisterVersionHandlers wires GET /api/version. The endpoint is public (no admin auth header required) and returns version, gitCommit, and buildDate of the running binary.

func RegisterViewerHandlers

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

func RequireElevatedPassword added in v0.17.0

func RequireElevatedPassword(ev ElevatedVerifier) gin.HandlerFunc

RequireElevatedPassword gates destructive operations behind a SECOND password supplied in the X-Admin-Password header (spec 004 / mp-e21). It is layered on top of AuthMiddleware, the route group already verified the main password, so this only adds the second factor. Attach it per-route to the gated subset (competition delete / invalidate / draw / overrides, participant roster mutations, CSV import) rather than to the whole group, since most admin routes stay single-factor.

Three outcomes, driven by the ElevatedVerifier contract:

  • GateActive() == false → c.Next(). File mode with no admin password set: the feature is opt-in, so destructive ops behave exactly as before (main password only). This is the back-compat path.
  • Configured() == false → 503. Locked mode with no/invalid TOURNAMENT_ADMIN_PASSWORD_HASH: fail closed rather than silently allowing destructive ops with the main password alone.
  • otherwise → Verify(X-Admin-Password); 401 on mismatch.

Per the product decision (re-prompt every time) there is no elevation token or session, the password travels on each gated request.

Types

type APIRateLimiter added in v0.17.0

type APIRateLimiter struct {
	// contains filtered or unexported fields
}

APIRateLimiter combines a global circuit breaker with per-IP fairness. Both layers are zero-config with sensible defaults:

  • Global: prevents total server overload (default 5000 req/s).
  • Per-IP: prevents a single client from starving others (default 100 req/s).

A request must pass BOTH checks. The per-IP check runs first (cheaper to reject a known-bad client before touching the shared global bucket).

func NewAPIRateLimiter added in v0.17.0

func NewAPIRateLimiter(globalRate float64, globalBurst int) *APIRateLimiter

NewAPIRateLimiter creates the two-layer rate limiter. The caller should call Close() on shutdown to stop the per-IP cleanup goroutine.

func (*APIRateLimiter) Close added in v0.17.0

func (rl *APIRateLimiter) Close()

Close stops the background cleanup goroutine.

func (*APIRateLimiter) Middleware added in v0.17.0

func (rl *APIRateLimiter) Middleware() gin.HandlerFunc

Middleware returns a gin.HandlerFunc that enforces both layers.

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)
	// LoadPoolMatches returns the pool-match results for compID.
	LoadPoolMatches(id string) ([]state.MatchResult, error)
	// LoadBracket returns the elimination bracket for compID.
	LoadBracket(id string) (*state.Bracket, 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
	// WithCourtExclusivityLock runs fn under the store's court-exclusivity
	// mutex, which must be acquired BEFORE any per-comp lock. Use this to
	// wrap a cross-competition court-busy check + WithTransaction pair so
	// two concurrent match-starts on the same court in different
	// competitions can't both pass the check before either commits (TOCTOU).
	// Mirrors state.Store.WithCourtExclusivityLock.
	WithCourtExclusivityLock(fn func() 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); handlers_match.go (score, mp-95mg, wraps WithCourtExclusivityLock + WithTransaction); handlers_decision.go (decision, T156).

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)
	RecordMatchResultWithIneligibilityTx(tx state.StoreTx, 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 + RecordMatchResultWithIneligibilityTx + 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. The write goes through the *Tx variant so the read-modify-write runs under the same per-comp lock the read used (see RegisterDaihyosenHandlers).

type DaihyosenStore

type DaihyosenStore interface {
	WithTransaction(compID string, fn func(tx state.StoreTx) error) error
}

DaihyosenStore is the consumer-boundary view of *state.Store used by the daihyosen handler. Both endpoints do a read-check-write on a single match, so they run entirely inside WithTransaction and read via the supplied StoreTx (LoadPoolMatches/LoadBracket for the match, plus LoadCompetition/LoadParticipants/LoadCompetitorStatus for the CHK026 eligibility count), keeping the read and the write atomic under one acquire of the per-comp lock. Calling the public Store.Load* methods inside the closure would deadlock (the lock is non-reentrant), so the store surface here is just the transaction entry point.

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 max (contract).

type ElevatedVerifier added in v0.17.0

type ElevatedVerifier interface {
	// GateActive reports whether the middleware should enforce at all.
	//   file mode:   true only when an admin password has been set.
	//   locked mode: always true (fail-closed, a locked deployment that
	//                forgot the env var must NOT silently allow destructive
	//                ops with the main password alone).
	GateActive() bool

	// Configured reports whether a credential exists to compare against.
	//   file mode:   admin password non-empty (== GateActive there).
	//   locked mode: TOURNAMENT_ADMIN_PASSWORD_HASH was a valid bcrypt hash.
	Configured() bool

	// Verify checks the presented X-Admin-Password value. Only consulted
	// when GateActive() && Configured(); implementations may still be called
	// defensively and must treat an empty presented value as a non-match.
	Verify(presented string) (bool, error)

	// Mode mirrors PasswordVerifier.Mode for /auth-config ("file"|"locked").
	Mode() string
}

ElevatedVerifier gates the destructive-operation middleware (RequireElevatedPassword) for spec 004 / mp-e21. It is intentionally NARROWER than PasswordVerifier: there is no bootstrap branch, no reset semantics, and no stored-password redaction policy, those belong to the primary credential. The elevated password is a SECOND factor layered on top of the main-password AuthMiddleware, so by the time these methods run the request has already proven it holds the main password.

Two orthogonal questions drive the middleware's three outcomes: GateActive() (should we enforce at all?) and Configured() (is there a credential to compare against?):

GateActive=false              -> pass through (file mode, no admin pw set;
                                 back-compat: main password alone suffices)
GateActive=true, Configured=false -> 503 "admin password not configured"
                                 (locked mode, env hash unset; fail-closed)
GateActive=true, Configured=true  -> compare X-Admin-Password via Verify()

func NewBcryptElevatedVerifier added in v0.17.0

func NewBcryptElevatedVerifier(hash string) (ElevatedVerifier, error)

NewBcryptElevatedVerifier validates `hash` as a bcrypt-encoded password hash and returns a configured locked-mode verifier. An empty hash is rejected with a distinct error so the caller can fall back to the unconfigured verifier (which 503s on use) rather than failing startup, a locked deployment that never performs destructive ops shouldn't be forced to set this env var just to boot.

func NewFileElevatedVerifier added in v0.17.0

func NewFileElevatedVerifier(store *state.Store) ElevatedVerifier

NewFileElevatedVerifier constructs the file-mode elevated verifier.

func NewLockedUnconfiguredElevatedVerifier added in v0.17.0

func NewLockedUnconfiguredElevatedVerifier() ElevatedVerifier

NewLockedUnconfiguredElevatedVerifier constructs the fail-closed locked verifier used when the elevated env-var hash is absent or malformed.

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"
	EventDrawGenerated  EventType = "draw_generated"
	EventDrawDiscarded  EventType = "draw_discarded"
	EventLineupUpdated  EventType = "lineup_updated"
	EventResyncRequired EventType = "resync_required"
)
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

	// HeartbeatInterval is the SSE keep-alive cadence (default 15s, set by
	// NewHubWithLimits). Exposed so tests can drive HandleEvents with a short
	// interval and observe a real emitted heartbeat frame rather than asserting
	// a hard-coded literal.
	HeartbeatInterval time.Duration
	// 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 (*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"` // "mixed", "playoffs", "league", or "swiss"; omit/empty for default (playoffs)
	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 LeagueTiebreakEngine added in v0.17.0

type LeagueTiebreakEngine interface {
	LeagueTiebreakCandidates(compID string) ([]engine.TiedGroup, error)
	GenerateLeagueTiebreakMatches(compID string, tiedTeamNames []string) ([]state.MatchResult, error)
	MaybeAutoCompletePools(compID string) (engine.AutoCompleteOutcome, error)
}

LeagueTiebreakEngine is the consumer-boundary view of *engine.Engine used by the league-tiebreak handler family. Methods are restricted to what these four endpoints actually call.

type LeagueTiebreakStore added in v0.17.0

type LeagueTiebreakStore interface {
	LoadCompetition(id string) (*state.Competition, error)
	LoadPoolMatches(id string) ([]state.MatchResult, error)
	SavePoolMatches(id string, matches []state.MatchResult) error
	UpdateCompetitionChanged(id string, transform func(current *state.Competition) (*state.Competition, error)) (bool, error)
	// WithTransaction holds the per-comp lock across a read-modify-write so the
	// DELETE handler's load→guard→filter→save can't lose a concurrent score
	// write that lands mid-sequence.
	WithTransaction(compID string, fn func(tx state.StoreTx) error) error
}

LeagueTiebreakStore is the consumer-boundary view of *state.Store used by the league-tiebreak handler family.

type LineupRequest

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

LineupRequest is the body for PUT /lineups/:round and the match-scoped PUT /match-lineups/:matchId. We accept only the positions map; teamID, round/matchID, and compID are pinned by the URL path.

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
	// CheckCrossCompCourtBusy checks whether the court for matchID is
	// already occupied by a running match in a different competition.
	// Must be called before entering WithTransaction to avoid a deadlock
	// (store.RunningMatchOnCourt acquires read locks on other competitions;
	// calling it while holding a write lock risks a circular wait).
	CheckCrossCompCourtBusy(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 and the
	// offline force-start feeder assertion). modifiedAt is the
	// server-relative timestamp for last-write-wins reconciliation (0 =
	// unstamped). Mirrors engine.Engine.OverrideBracketWinner.
	OverrideBracketWinner(compID string, matchID string, winnerName string, modifiedAt int64) (bool, 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)
	// CheckKachinukiPrematureCompletion rejects a completed-status write
	// that would finalize a kachinuki match while both rosters still
	// have players and no daihyosen resolves the tie
	// (engine.ErrKachinukiPrematureCompletion, mapped to 409). No-op for
	// non-kachinuki competitions and non-completed writes. Mirrors
	// engine.Engine.CheckKachinukiPrematureCompletion.
	CheckKachinukiPrematureCompletion(compID, matchID string, result *state.MatchResult) 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
	// DeleteTeamLineupForMatch is the match-scoped twin added for
	// per-match lineups (mp-825).
	DeleteTeamLineupForMatch(compID, teamID, matchID string) error
}

TeamLineupStore is the consumer-boundary view of state.Store used by handlers_lineup.go (Slice 7.B / T127). Mirrors the LoadTeamLineups / SetTeamLineup / DeleteTeamLineup 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 TournamentLoader added in v0.17.0

type TournamentLoader interface {
	LoadTournament() (*state.Tournament, error)
}

TournamentLoader is the consumer-boundary view of state.Store used by handlers that need to inspect the tournament-level configuration (e.g. Mode) without taking a dependency on the full store. *state.Store satisfies this interface by structural match.

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