felt/.planning/phases/01-tournament-engine/01-PLAN-C.md
Mikkel Georgsen 21ff95068e docs(01): create Phase 1 plans (A-N) with research and feedback
14 plans in 6 waves covering all 68 requirements for the Tournament
Engine phase. Includes research (go-libsql, NATS JetStream, Svelte 5
runes, ICM complexity), plan verification (2 iterations), and user
feedback (hand-for-hand UX, SEAT-06 reword, re-entry semantics,
integration test, DKK defaults, JWT 7-day expiry, clock tap safety).

Wave structure:
  1: A (scaffold), B (schema)
  2: C (auth/audit), D (clock), E (templates), J (frontend scaffold)
  3: F (financial), H (seating), M (layout shell)
  4: G (player management)
  5: I (tournament lifecycle)
  6: K (overview/financials), L (players), N (tables/more)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:58:22 +01:00

9.5 KiB

Plan C: Authentication + Audit Trail + Undo Engine


wave: 2 depends_on: [01-PLAN-A, 01-PLAN-B] files_modified:

  • internal/auth/pin.go
  • internal/auth/jwt.go
  • internal/server/middleware/auth.go
  • internal/server/middleware/role.go
  • internal/audit/trail.go
  • internal/audit/undo.go
  • internal/server/routes/auth.go
  • internal/auth/pin_test.go
  • internal/audit/trail_test.go
  • internal/audit/undo_test.go autonomous: true requirements: [AUTH-01, AUTH-03, ARCH-08, PLYR-06]

Goal

Operators authenticate with a PIN that produces a local JWT with role claims. Every state-changing action writes an immutable audit trail entry to LibSQL and publishes to NATS JetStream. Any financial action or bust-out can be undone with full state reversal and re-ranking. This is the security and accountability foundation for the entire system.

Context

  • PIN login flow: Operator enters PIN → bcrypt compare against all operators → JWT issued with role claim (admin/floor/viewer)
  • Rate limiting: Exponential backoff after 5 failures, lockout after 10 (AUTH-05 spec, applied here to operator PIN too)
  • JWT: HS256 signed, 7-day expiry (local system, not internet-exposed — prevents mid-tournament logouts during 12+ hour sessions), claims: sub (operator ID), role, iat, exp
  • Audit trail: Every mutation writes to audit_entries table AND publishes to NATS tournament.{id}.audit
  • Undo: Creates a NEW audit entry that reverses the original — never deletes/modifies existing entries
  • See 01-RESEARCH.md Pattern 4 (Event-Sourced Audit Trail), PIN Authentication Flow

User Decisions (from CONTEXT.md)

  • Undo is critical — bust-outs, rebuys, add-ons, buy-ins can all be undone with full re-ranking
  • Late registration soft lock with admin override — logged in audit trail
  • Operator is the Tournament Director (TD)
  • Roles: Admin (full control), Floor (runtime actions), Viewer (read-only)

Tasks

**1. PIN Service** (`internal/auth/pin.go`): - `AuthService` struct with db, signing key, rate limiter - `Login(ctx, pin string) (token string, operator Operator, err error)`: - Check rate limiter — return `ErrTooManyAttempts` if blocked - Load all operators from DB - Try bcrypt.CompareHashAndPassword against each operator's pin_hash - On match: reset failure counter, issue JWT, return token + operator - On no match: record failure, return `ErrInvalidPIN` - Rate limiter (in-memory, per-IP or global for simplicity): - Track consecutive failures - After 5 failures: 30-second delay - After 8 failures: 5-minute delay - After 10 failures: 30-minute lockout - Reset on successful login - `HashPIN(pin string) (string, error)` — bcrypt with cost 12 (for seed data and operator management) - `CreateOperator(ctx, name, pin, role string) error` — insert operator with hashed PIN - `ListOperators(ctx) ([]Operator, error)` — for admin management - `UpdateOperator(ctx, id, name, pin, role string) error`

2. JWT Service (internal/auth/jwt.go):

  • NewToken(operatorID, role string) (string, error) — creates HS256-signed JWT with claims: sub, role, iat, exp (24h)
  • ValidateToken(tokenString string) (*Claims, error) — parses and validates JWT, returns claims
  • Claims struct: OperatorID string, Role string (admin/floor/viewer)
  • Signing key: generated randomly on first startup, stored in LibSQL _config table (persisted across restarts)

3. Auth Middleware (internal/server/middleware/auth.go):

  • Extract JWT from Authorization: Bearer <token> header
  • Validate token, extract claims
  • Store claims in request context (context.WithValue)
  • Return 401 if missing/invalid/expired
  • Public routes (health, static files, WebSocket initial connect) bypass auth

4. Role Middleware (internal/server/middleware/role.go):

  • RequireRole(roles ...string) middleware factory
  • Extract claims from context, check if role is in allowed list
  • Return 403 if insufficient role
  • Role hierarchy: admin > floor > viewer (admin can do everything floor can do)

5. Auth Routes (internal/server/routes/auth.go):

  • POST /api/v1/auth/login — body: {"pin": "1234"} → response: {"token": "...", "operator": {...}}
  • GET /api/v1/auth/me — returns current operator from JWT claims
  • POST /api/v1/auth/logout — client-side only (JWT is stateless), but endpoint exists for audit logging

6. Tests (internal/auth/pin_test.go):

  • Test successful login returns valid JWT
  • Test wrong PIN returns ErrInvalidPIN
  • Test rate limiting kicks in after 5 failures
  • Test lockout after 10 failures
  • Test successful login resets failure counter
  • Test JWT validation with expired token returns error
  • Test role middleware blocks insufficient roles

Verification:

  • POST /api/v1/auth/login with PIN "1234" returns a JWT token
  • The token can be used to access protected endpoints
  • Wrong PIN returns 401
  • Rate limiting activates after 5 rapid failures
  • Role middleware blocks floor from admin-only endpoints
**1. Audit Trail** (`internal/audit/trail.go`): - `AuditTrail` struct with db, nats publisher - `Record(ctx, entry AuditEntry) error`: - Generate UUID for entry ID - Set timestamp to `time.Now().UnixNano()` - Extract operator ID from context (set by auth middleware) - Insert into `audit_entries` table in LibSQL - Publish to NATS JetStream subject `tournament.{tournament_id}.audit` - If tournament_id is empty (venue-level action), publish to `venue.audit` - `AuditEntry` struct per 01-RESEARCH.md Pattern 4: - ID, TournamentID, Timestamp, OperatorID, Action, TargetType, TargetID - PreviousState (json.RawMessage), NewState (json.RawMessage) - Metadata (json.RawMessage, optional) - UndoneBy (*string, nullable) - `GetEntries(ctx, tournamentID string, limit, offset int) ([]AuditEntry, error)` — paginated audit log - `GetEntry(ctx, entryID string) (*AuditEntry, error)` — single entry lookup - Action constants: define all action strings as constants: - `player.buyin`, `player.bust`, `player.rebuy`, `player.addon`, `player.reentry` - `financial.buyin`, `financial.rebuy`, `financial.addon`, `financial.payout`, `financial.chop`, `financial.bubble_prize` - `clock.start`, `clock.pause`, `clock.resume`, `clock.advance`, `clock.rewind`, `clock.jump` - `seat.assign`, `seat.move`, `seat.balance`, `seat.break_table` - `tournament.create`, `tournament.start`, `tournament.end`, `tournament.cancel` - `template.create`, `template.update`, `template.delete` - `operator.login`, `operator.logout` - `undo.*` — mirrors each action type

2. Undo Engine (internal/audit/undo.go):

  • UndoEngine struct with db, audit trail, nats publisher
  • Undo(ctx, auditEntryID string) error:
    • Load the original audit entry
    • Verify it hasn't already been undone (check undone_by field)
    • Create a NEW audit entry with:
      • Action: undo.{original_action} (e.g., undo.player.bust)
      • PreviousState: original's NewState
      • NewState: original's PreviousState
      • Metadata: {"undone_entry_id": "original-id"}
    • Update the original entry's undone_by field to point to the new entry (this is the ONE exception to append-only — it marks an entry as undone, not deleting it)
    • Return the undo entry for the caller to perform the actual state reversal
  • CanUndo(ctx, auditEntryID string) (bool, error) — checks if entry is undoable (not already undone, action type is undoable)
  • Undoable actions list: player.bust, player.buyin, player.rebuy, player.addon, player.reentry, financial.*, seat.move, seat.balance
  • Non-undoable actions: clock.*, tournament.start, tournament.end, operator.*

3. Integration with existing infrastructure:

  • Add AuditTrail to the server struct, pass to all route handlers
  • Audit trail is a cross-cutting concern — every route handler that mutates state calls audit.Record()
  • Wire up NATS publisher to audit trail

4. Tests (internal/audit/trail_test.go, internal/audit/undo_test.go):

  • Test audit entry is persisted to LibSQL with all fields
  • Test audit entry is published to NATS JetStream
  • Test undo creates new entry and marks original as undone
  • Test double-undo returns error
  • Test non-undoable action returns error
  • Test GetEntries pagination works correctly
  • Test operator ID is extracted from context

Verification:

  • Every state mutation creates an audit_entries row with correct previous/new state
  • NATS JetStream receives the audit event on the correct subject
  • Undo creates a reversal entry and marks the original
  • Double-undo is rejected
  • Audit log is queryable by tournament, action type, and time range

Verification Criteria

  1. PIN login produces valid JWT with role claims
  2. Auth middleware rejects requests without valid JWT
  3. Role middleware enforces admin/floor/viewer hierarchy
  4. Rate limiting activates after 5 failed login attempts
  5. Every state mutation produces an audit_entries row
  6. Undo creates a new audit entry (never deletes the original)
  7. Double-undo is rejected with clear error
  8. All tests pass

Must-Haves (Goal-Backward)

  • Operator PIN → local JWT works offline (no external IdP dependency)
  • Three roles (Admin, Floor, Viewer) enforced on all API endpoints
  • Every state-changing action writes an append-only audit trail entry
  • Financial transactions and bust-outs can be undone with audit trail reversal
  • Undo never deletes or overwrites existing audit entries (only marks them and creates new ones)