6.3 KiB
Application API
All application responses are JSON { "ok": true, "status": 200, "payload": ... }, or { "ok": false, "status": ..., "code": ..., "message": ... }. The HTTP status agrees with the application status. Health and legacy simulator routes keep Ligo's original envelope. Player APIs return current projections; the development simulator alone exposes snapshot history.
Authentication
fpol_session is a random opaque cookie, HttpOnly, SameSite=Lax, Path=/, with a 30-day lifetime. Only its SHA-256 digest is stored. Production requires --secure-cookies and an HTTPS --origin. Tokens never appear in HTTP JSON. Every POST needs Origin exactly matching --origin and X-Fpol-Request: 1; authenticated POSTs also need X-CSRF-Token from login or GET /api/auth/me. There is no cross-origin API/CORS access.
POST /api/auth/login:username, password. Returns the user (id, username, admin, csrf) and sets the cookie.POST /api/auth/register:username, password, invite, team_name. A valid invitation and available seat are checked again transactionally after hashing; a failed join does not create an orphan account.GET /api/auth/me: current user and CSRF token.POST /api/auth/logout: invalidates the session and clears the cookie.POST /api/auth/password:current_password, password. Rotates all sessions, returning a new one.POST /api/auth/reset:reset_token, password. Single-use operator token; revokes all sessions and reset tokens for that account. Sign in afterward.
Usernames have 3–32 ASCII letters, numbers or underscores, case-insensitive. Passwords have 12–256 UTF-8 bytes. Hashes use OpenSSL scrypt N=131072/r=8/p=1, an independent 128-bit salt, and a 512-bit result. Two hashing workers serve at most eight in-flight jobs, off the HTTP loop. Login attempts are transactionally limited to 10 per username and 100 per transport peer in 15 minutes; behind Caddy that peer allowance is shared by all players. Password recovery is CLI-only, with a 30-minute expiry. Database files/backups contain secrets and need private storage.
Leagues
All routes require a session. League-scoped reads and writes require membership. An administrator has no implicit access to other players' leagues.
| Method / path | Input |
|---|---|
GET leagues |
none; lists own memberships |
POST league/create |
name, team_name, max_teams; optional catalog_id (latest real catalog by default) |
POST league/join |
invite, team_name |
GET league |
query league_id; includes own queue, teams, picks, timer, readiness and recent events |
POST team/name |
league_id, name |
POST league/invite |
commissioner; league_id; returns a seven-day bearer invitation; replaces prior invitation |
POST league/revoke-invites |
commissioner; league_id |
POST league/remove |
commissioner; league_id, team_id; before draft only; also revokes existing invitations |
POST league/name |
commissioner; league_id, name; rename without changing frozen rules |
POST league/ratings |
commissioner; league_id, document; immutable sourced ratings JSON while forming |
POST league/settings |
commissioner; league_id, name, catalog_id, ratings_id (nullable); before draft only; clears queues |
No public registration, public league directory, or email invitation service exists. An invitation is reusable until the league fills, it expires, it is revoked, or drafting starts. One account owns at most one team per league. The commissioner cannot remove their own team.
Drafts
POST draft/start,draft/pause,draft/resume: commissioner,league_id.POST draft/queue:league_id, candidates(ordered unique candidate IDs, maximum 200). Other members never receive this queue.POST draft/pick:league_id, candidate_id, pick_number, request_id. Use a unique request ID per attempted pick. Retrying the same body/ID returns the original pick; reusing an ID for another body fails with 409. Candidate and slot exclusivity are database constraints. The draft clock and human picks use the same serialized, transactional path; a pick at or after deadline fails and the timer advances the turn.
The draft starts only with 2+ teams, enough global/per-party supply, ratings for every race, and a complete national catalog plus enabled verified count/outcome coverage for real races. The compiled inventory includes 435 House seats, 35 Senate contests (including FL/OH specials), and 36 governor races. The practice pool bypasses real-data gates only under --allow-demo.
Candidates and standings
GET catalogs: catalog/rating summaries, no snapshot history. Demo summaries require--allow-demo.GET candidates: optionalleague_id(pins frozen catalog/ratings/ownership),catalog_id,search,office,state,affiliation,available=true,offset,limit(1–100).itemsincludes source provenance and rating/owner when applicable.GET results: optionalleague_idorcatalog_id; current race counts and separate outcomes. Includes received/source/check timestamps and stale status. No observations are invented for missing races.GET standings: requiredleague_id; team rosters, candidate score components, known subtotal, full total only when all 12 scores are known, reporting coverage, provisional flag, and tied competition ranks. Teams with no known scores are unranked.GET admin/sources: administrator only; current source health, assigned coverage, 50 recent quarantines.
GET parameters are percent-decoded, + becomes a space, and duplicate/malformed parameters are rejected by the opted-in Ligo query parser. HTTP requests are capped at 64 KiB. There is no remote admin import/reset/SQL endpoint. fpol_admin app-request FILE|- is a trusted local test/operator interface that may return session secrets and accepts an injected clock; it is never an HTTP route.
Persistence
Migration 001_documents stays unchanged. 002_leagues adds accounts, sessions, invites, leagues, teams, draft clocks, picks, idempotency commands, events, source configuration, raw payloads, observations, current projections, coverage and quarantine. SQLite WAL, foreign keys and a busy timeout are enabled. One application DB worker owns each short BEGIN IMMEDIATE transaction. Hashing and network polling happen outside these transactions. The ingestion process uses its own connection to the same volume.