v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-26 20:58:19 -04:00
1 parent 5d2be4abb2
commit d0326b059e
66 files changed
+4016 -2433

No files matched your search

+117 -79
View File
@@ -12,33 +12,47 @@ Central command handler containing business logic. Single `Execute(Command)` ent
In-memory live-game storage with authentication support. A mutex protects each
game transition and callers receive immutable game views rather than mutable
game pointers. The service manages game lifecycle, snapshots, player
configuration, user accounts, JWT tokens, persistence, and terminal-game
eviction. Eviction removes only the memory copy; durable replay data remains.
configuration, user accounts, JWT tokens, persistence, and cleanup. Finished
games leave memory after an hour while their durable rows remain; games with
no registered player leave memory and the database 24 hours after their last
activity (see Retention below).
#### Long-Polling Registry (`internal/service/waiter.go`)
Manages clients waiting for game state changes via HTTP long-polling. Tracks move counts per client, sends notifications on state changes, enforces 30-second timeout. Non-blocking notification pattern handles slow clients gracefully. Coordinates with service layer for game updates and deletion events.
#### Authentication Module (`internal/service/user.go`, `internal/http/auth.go`)
- **Password Hashing**: Argon2id for secure password storage
- **JWT Management**: HS256 tokens with 7-day expiration
- **Password Hashing**: Argon2id (`lixenwraith/auth`), at most four concurrent
derivations; unknown accounts verify against a dummy hash for equal timing
- **JWT Management**: one scoped `auth.JWT` manager (HS256, issuer
`chess-server`, audience `chess-api`, 7-day lifetime, zero leeway); the key
comes from `-jwt-secret-file`, a fixed dev key, or a per-process random key
- **Bearer Parsing**: `auth.ParseBearerToken` (RFC 6750 syntax)
- **User Operations**: Registration, login, profile management
- **Session Tracking**: One persisted session per user, with JWT subject/session
binding and last-login timestamps
### Storage Layer (`internal/server/storage`)
SQLite persistence with ordered asynchronous writes for gameplay and
synchronous writes for authentication. A bounded channel (1,000 operations)
feeds one transactional writer. Move persistence groups the move, first-move
slot claim, and terminal result in one transaction. Replay reads insert a
barrier behind accepted writes and then read the game and moves in one SQLite
PostgreSQL 18 persistence through pgx v5 (`database/sql` via `pgx/stdlib`),
with ordered asynchronous writes for gameplay and synchronous writes for
accounts. A bounded channel (1,000 operations) feeds one transactional writer,
which preserves per-game ordering (a move never overtakes its game's insert or
a rewind). Move persistence groups the move, first-move slot claim, and
terminal result in one transaction. Replay reads insert a barrier behind
accepted writes and then read the game and moves in one REPEATABLE READ
snapshot.
WAL, foreign-key enforcement, a five-second busy timeout, and NORMAL
synchronous mode are configured in the connection string so every pooled
connection receives the same settings. The pool is deliberately small (eight
open, four idle) because SQLite has one writer. A write failure or full queue
marks storage degraded; live play remains available in memory and `/health`
reports that durable history may be incomplete.
The writer retries a transaction that failed before COMMIT with a transient
error (lost connection, administrator shutdown, serialization failure,
deadlock) with doubling backoff for about three seconds, so a PostgreSQL
restart does not degrade storage. A COMMIT error (unknown outcome), a permanent
error, or a full queue marks storage degraded; live play remains available in
memory and `/health` reports that durable history may be incomplete.
Synchronous operations carry a five-second deadline. The pool holds at most ten
connections (five idle). The schema is resolved through `search_path`, so the
role default (`chess`) or a DSN parameter selects it. `InitDB` applies ordered,
transactional migrations under an advisory lock and issues no DDL when the
schema is current, which lets a DML-only runtime role start the server.
### Supporting Modules
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
@@ -53,9 +67,9 @@ reports that durable history may be incomplete.
### User Registration
1. HTTP handler receives `POST /auth/register` with credentials
2. Validates username format and password strength
3. Service layer hashes password with Argon2id
4. Creates user record with unique ID (collision detection)
5. Generates JWT token
3. Service layer hashes password with Argon2id (bounded concurrency)
4. Creates the user and its initial session in one transaction (random UUIDv4 ID)
5. Generates a scoped JWT bound to that session
6. Returns token and user information
### Authenticated Game Creation
@@ -74,7 +88,7 @@ reports that durable history may be incomplete.
5. If legal, gets new FEN from engine
6. Calls `service.ApplyMoveWithState()` with the FEN, turn, and state that were validated
7. Service rejects a stale concurrent commit or atomically updates the move, optional slot claim, and terminal result
8. The same logical mutation is queued as one SQLite transaction
8. The same logical mutation is queued as one database transaction
9. Returns GameResponse
### Computer Move
@@ -100,19 +114,22 @@ reports that durable history may be incomplete.
1. Client requests `GET /api/v1/games/{id}/history`
2. Storage queues a barrier after all previously accepted gameplay writes
3. The writer reaches the barrier only after those transactions finish
4. Storage reads the game row and ordered moves in one read transaction
4. Storage reads the game row and ordered moves in one REPEATABLE READ transaction
5. The API returns the initial FEN plus every UCI move and resulting FEN
6. This path works after terminal-memory eviction or a server restart
## Persistence Flow
### User Write Operations (Synchronous)
1. Service serializes public registrations within the process
2. Storage checks uniqueness and capacity in a transaction
3. At capacity, the oldest temporary user is evicted in that transaction
4. The new user and initial session commit together; any failure rolls back the entire operation
5. Login replaces the user's single session with one SQLite UPSERT
6. Other account mutations commit before success is returned
1. The password is hashed outside any lock, within the KDF concurrency bound
2. Storage takes a transaction-scoped advisory lock, so account creation is
serialized across the server and CLI processes
3. Uniqueness is checked first, so a duplicate is reported even when registration is full
4. Public registration is refused once `-max-users` accounts exist; nothing is evicted
5. The new user and initial session commit together; any failure rolls back the entire operation
6. Login replaces the user's single session and records `last_login_at` in one statement
7. Other account mutations commit before success is returned; unique violations map to "already exists"
8. Registered and CLI-created accounts are identical and never expire
### Game Write Operations (Asynchronous)
1. Service layer calls a storage method (`RecordNewGame`, `RecordMove`, `RecordPlayers`, or `RewindGame`)
@@ -124,10 +141,14 @@ reports that durable history may be incomplete.
### Query Operations
1. Replay-sensitive game reads wait on the async-write barrier
2. User-game filtering matches creation-time player IDs and later claim IDs
3. Move lookup uses the `UNIQUE(game_id, move_number)` index prefix and returns move order directly
4. Username and email lookups are case-insensitive
5. CLI database queries format records as tabular output
2. "My games" matches claims (`white_claimed_by`/`black_claimed_by`) through two
composite partial indexes; authenticated creators and first movers always
record a claim, and claims survive player reconfiguration
3. The listing reads each game's move count and final FEN from its last move
with a LATERAL probe of the `moves` primary key, not a per-row count
4. Move history is read in primary-key order `(game_id, move_number)`
5. Usernames and emails are stored lowercase; lookups lower their input
6. Malformed IDs are treated as "not found" before reaching the database
## Concurrency
@@ -137,7 +158,8 @@ reports that durable history may be incomplete.
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
- **Validation Engine**: Single mutex-protected instance for synchronous validation
- **Storage Writer**: Single goroutine processes game write queue sequentially
- **User Operations**: Direct database access with transaction isolation
- **User Operations**: Direct database access; account creation serialized by a PostgreSQL advisory lock
- **Password Hashing**: Four Argon2id slots; waiters give up after five seconds (HTTP 503)
- **PID Lock**: File-based exclusive lock prevents multiple instances
## Data Structures
@@ -165,15 +187,18 @@ type Snapshot struct {
```
### JWT Claims
```go
```json
{
"iss": "chess-server",
"aud": ["chess-api"],
"sub": "user-id",
"username": "alice",
"email": "alice@example.com",
"session_id": "session-id",
"exp": 1234567890
"iat": 1234567890,
"nbf": 1234567890,
"exp": 1235172690,
"extra": {"session_id": "session-id"}
}
```
Profile data is not placed in the token; clients read it from `/auth/me`.
### Command Pattern with User Context
Commands encapsulate operations with type, arguments, and optional user ID for authenticated requests.
@@ -181,67 +206,79 @@ Commands encapsulate operations with type, arguments, and optional user ID for a
### Player Configuration
Players identified by UUID (authenticated users) or generated IDs (anonymous), configured with type (human/computer), skill level, and search time.
### Storage Schema
### Storage Schema (version 1)
```sql
-- User authentication table
users (
user_id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
email TEXT COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME
user_id uuid PRIMARY KEY,
username text NOT NULL UNIQUE, -- lowercase, 1-64 characters
email text UNIQUE, -- lowercase or NULL
password_hash text NOT NULL, -- Argon2id PHC string
created_at timestamptz NOT NULL,
last_login_at timestamptz
)
sessions (
session_id uuid PRIMARY KEY,
user_id uuid NOT NULL UNIQUE REFERENCES users ON DELETE CASCADE,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL
)
-- Game storage with player associations
games (
game_id TEXT PRIMARY KEY,
initial_fen TEXT,
white_player_id TEXT, -- User ID or generated UUID
white_type INTEGER,
white_level INTEGER,
white_search_time INTEGER,
black_player_id TEXT, -- User ID or generated UUID
black_type INTEGER,
black_level INTEGER,
black_search_time INTEGER,
start_time_utc DATETIME,
result TEXT, -- white_wins, black_wins, draw, or stalemate
end_time_utc DATETIME,
white_claimed_by TEXT, -- user that claimed the slot after creation
black_claimed_by TEXT
game_id uuid PRIMARY KEY,
initial_fen text NOT NULL,
white_player_id uuid NOT NULL, -- user ID or generated UUID
white_type smallint NOT NULL, -- 1 human, 2 computer
white_level smallint NOT NULL, -- 0-20
white_search_time integer NOT NULL,
white_claimed_by uuid, -- user that owns the slot
white_name text, -- claimant's username at claim time
black_... (same columns),
start_time_utc timestamptz NOT NULL,
result text, -- white_wins, black_wins, draw, stalemate
end_time_utc timestamptz, -- set exactly when result is set
)
-- Move history
moves (
move_id INTEGER PRIMARY KEY,
game_id TEXT,
move_number INTEGER,
move_uci TEXT,
fen_after_move TEXT,
player_color TEXT,
move_time_utc DATETIME,
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
UNIQUE (game_id, move_number)
game_id uuid REFERENCES games ON DELETE CASCADE,
move_number integer, -- 1..n without gaps
move_uci text NOT NULL, -- [a-h][1-8][a-h][1-8][qrbn]?
fen_after_move text NOT NULL,
player_color text NOT NULL, -- 'w' | 'b'
move_time_utc timestamptz NOT NULL,
PRIMARY KEY (game_id, move_number)
)
```
Schema version 2 is applied idempotently to legacy databases with guarded
`ALTER TABLE ADD COLUMN` migrations. Redundant indexes formerly duplicating
UNIQUE constraints or useful index prefixes are removed. Purpose-built partial
indexes cover non-empty email uniqueness, temporary-user cleanup, session
expiry, and post-creation game claims.
`schema_version` records the applied version. Indexes cover session expiry,
each claim column ordered by `(start_time_utc DESC, game_id DESC)` for the game
listing, and the start time of unclaimed games for the anonymous-game purge.
Claims and player IDs deliberately have no foreign key to `users`: history
outlives deleted accounts, and the name snapshot keeps it readable.
### Retention
- Sessions are deleted after expiry (7 days after the last login).
- Finished games leave memory after `-finished-game-ttl` (1 hour); their rows stay.
- Games with no claimed slot are unloaded from memory and deleted from the
database 24 hours after their last activity (`-anonymous-game-ttl`). The
hourly cleanup evicts idle games first, then queues the delete through the
ordered writer while excluding every game still loaded, so no live game can
lose its row. See [`deploy/postgresql`](../deploy/postgresql) for
provisioning and the one-time SQLite import.
## Security Architecture
### Authentication Flow
1. Password validation enforces minimum complexity
2. Argon2id hashing prevents rainbow table attacks
3. JWT tokens expire after 7 days
4. Case-insensitive username/email matching prevents enumeration
5. Constant-time password verification prevents timing attacks
3. JWT tokens expire after 7 days and must name this service as issuer/audience
4. Case-insensitive username/email matching prevents duplicate accounts
5. Unknown accounts and wrong passwords cost the same Argon2id work and
return the same error
### Rate Limiting Strategy
- Client IP: the `-proxy-header` value (default `X-Real-IP`) only on connections
from `-trusted-proxies`; otherwise the TCP peer
- General API: 10 req/s per IP (20 in dev mode)
- Registration: 5 req/min per IP (prevent spam accounts)
- Login: 10 req/min per IP (prevent brute force)
@@ -249,7 +286,8 @@ expiry, and post-creation game claims.
### Data Protection
- Passwords never stored in plaintext
- JWT secret rotates on restart (or fixed in dev mode)
- JWT key from a 0600 key file, or rotated on restart (fixed in dev mode)
- Database access by Unix-socket peer authentication; no stored DB password
- User IDs use UUIDs with collision detection
- Transactions keep registration, sessions, moves, claims, results, and rewinds internally consistent
- Case-insensitive queries prevent duplicate accounts