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>
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_entriestable AND publishes to NATStournament.{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
_configtable (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 claimsPOST /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/loginwith 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
2. Undo Engine (internal/audit/undo.go):
UndoEnginestruct with db, audit trail, nats publisherUndo(ctx, auditEntryID string) error:- Load the original audit entry
- Verify it hasn't already been undone (check
undone_byfield) - 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"}
- Action:
- Update the original entry's
undone_byfield 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
AuditTrailto 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
- PIN login produces valid JWT with role claims
- Auth middleware rejects requests without valid JWT
- Role middleware enforces admin/floor/viewer hierarchy
- Rate limiting activates after 5 failed login attempts
- Every state mutation produces an audit_entries row
- Undo creates a new audit entry (never deletes the original)
- Double-undo is rejected with clear error
- 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)