v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
5d2be4abb2
commit
d0326b059e
66 files changed
+4016
-2433
No files matched your search
+34
-14
@@ -26,6 +26,10 @@ Creates new user account and returns JWT token.
|
||||
- `email` (string, optional): Valid email address
|
||||
- `password` (string, required): Minimum 8 characters, must contain letter and number
|
||||
|
||||
Accounts created here are identical to CLI-created accounts and do not
|
||||
expire. Returns 409 when the username or email is taken, and 503 when the
|
||||
registration limit (`-max-users`) or password-hashing capacity is exhausted.
|
||||
|
||||
**Response (201):**
|
||||
```json
|
||||
{
|
||||
@@ -101,7 +105,7 @@ Returns server and storage status.
|
||||
```
|
||||
|
||||
Storage states:
|
||||
- `"disabled"` - No storage path configured
|
||||
- `"disabled"` - No database configured (`-dsn` or `CHESS_DSN`)
|
||||
- `"ok"` - Database operational with auth enabled
|
||||
- `"degraded"` - A persistence write failed or the write queue filled; live games continue in memory, but durable history is no longer complete
|
||||
|
||||
@@ -177,7 +181,9 @@ Response includes all game data. Compare `moves` array length to detect changes.
|
||||
`GET /games/{gameId}/history`
|
||||
|
||||
Returns the persisted replay line even after the live game has been unloaded
|
||||
from memory or the server has restarted. History is public to anyone who knows
|
||||
from memory or the server has restarted. Games without a registered player
|
||||
are deleted 24 hours after their last activity; their history then returns
|
||||
404. History is public to anyone who knows
|
||||
the game ID, matching the existing public live-game read model. Persistent
|
||||
storage must be enabled.
|
||||
|
||||
@@ -193,7 +199,7 @@ client can replay the game without running a chess engine.
|
||||
"startTimeUtc": "2026-09-07T12:00:00Z",
|
||||
"endTimeUtc": "2026-09-07T12:15:00Z",
|
||||
"players": {
|
||||
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id"},
|
||||
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"},
|
||||
"black": {"id": "player-id", "color": 2, "type": 1}
|
||||
},
|
||||
"moves": [
|
||||
@@ -209,10 +215,12 @@ client can replay the game without running a chess engine.
|
||||
```
|
||||
|
||||
`result` is omitted while a game is ongoing. Persisted terminal values are
|
||||
`white_wins`, `black_wins`, `draw`, and `stalemate`.
|
||||
`white_wins`, `black_wins`, `draw`, and `stalemate`. A claimed player's `name`
|
||||
is their username when the claim was recorded; it survives later renames and
|
||||
account deletion, and is omitted for anonymous and computer players.
|
||||
|
||||
Returns 404 when the game has no durable record and 503 when persistence is
|
||||
disabled.
|
||||
Returns 400 for a non-canonical game ID, 404 when the game has no durable
|
||||
record, and 503 when persistence is disabled or degraded.
|
||||
|
||||
### List My Stored Games
|
||||
`GET /users/me/games?limit=50&offset=0`
|
||||
@@ -224,8 +232,11 @@ persistent storage.
|
||||
- `limit`: 1-100; defaults to 50
|
||||
- `offset`: 0-1,000,000; defaults to 0
|
||||
|
||||
Each item contains game ID, initial FEN, result/timestamps, players, and move
|
||||
count. `nextOffset` is present only when another page exists.
|
||||
Each item contains game ID, initial FEN, final FEN (after the last stored move,
|
||||
or the initial FEN when there is none), result/timestamps, players, and move
|
||||
count. `nextOffset` is present only when another page exists. A game belongs to
|
||||
a user who created it while authenticated or claimed a slot with a first move;
|
||||
the association survives later player reconfiguration.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
@@ -234,12 +245,13 @@ count. `nextOffset` is present only when another page exists.
|
||||
{
|
||||
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||
"finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
|
||||
"result": "white_wins",
|
||||
"startTimeUtc": "2026-09-07T12:00:00Z",
|
||||
"endTimeUtc": "2026-09-07T12:15:00Z",
|
||||
"moveCount": 41,
|
||||
"moveCount": 7,
|
||||
"players": {
|
||||
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id"},
|
||||
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"},
|
||||
"black": {"id": "player-id", "color": 2, "type": 1}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +326,12 @@ Error codes:
|
||||
- Registration: 5 requests/minute/IP
|
||||
- Login: 10 requests/minute/IP
|
||||
|
||||
Exceeding limit returns 429 status.
|
||||
Exceeding limit returns 429 status. Behind a reverse proxy the client IP is the
|
||||
proxy's `X-Real-IP` header, trusted only from addresses listed in
|
||||
`-trusted-proxies`.
|
||||
|
||||
Password hashing is bounded to four concurrent operations. When all are busy
|
||||
for five seconds, registration and login return 503 with `RESOURCE_LIMIT`.
|
||||
|
||||
## JWT Token Format
|
||||
|
||||
@@ -323,6 +340,9 @@ Tokens are HS256-signed JWTs valid for 7 days. Include in Authorization header:
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Token claims include `sub` (user ID), `username`, `email`, `session_id`, and
|
||||
`exp` (expiration). Authentication requires the session to exist, be unexpired,
|
||||
and belong to the JWT subject.
|
||||
The scheme is case-insensitive and the token must use RFC 6750 syntax; a
|
||||
malformed header returns 401 even on endpoints where authentication is
|
||||
optional. Claims are `iss` (`chess-server`), `aud` (`chess-api`), `sub` (user
|
||||
ID), `iat`, `nbf`, `exp`, and `extra.session_id`. Tokens carry no profile data;
|
||||
use `/auth/me`. Authentication requires the session to exist, be unexpired, and
|
||||
belong to the JWT subject. Logging in again replaces the previous session.
|
||||
+117
-79
@@ -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
|
||||
@@ -0,0 +1,286 @@
|
||||
# Deployment: FreeBSD Jail with PostgreSQL 18
|
||||
|
||||
Production layout: host nginx terminates TLS and reverse-proxies `/chess/` to
|
||||
`chess-server` in a Bastille jail. PostgreSQL 18 runs in the same jail and
|
||||
listens **only on its Unix socket**. The `chess` OS account has no shell or
|
||||
password; its database role reaches only the `chess` database through peer
|
||||
authentication, so no database password exists.
|
||||
|
||||
```
|
||||
internet ──TLS──▶ host nginx ──HTTP──▶ jail: chess-server (API port)
|
||||
│ Unix socket, peer auth
|
||||
▼
|
||||
jail: PostgreSQL 18 (no TCP listener)
|
||||
```
|
||||
|
||||
`host#` runs on the Bastille host as root; `jail#` runs inside the jail as root
|
||||
(`bastille console <jail>`).
|
||||
|
||||
## Scripted Deployment
|
||||
|
||||
The scripts in [`deploy/`](../deploy) perform every step below and are safe to
|
||||
rerun for upgrades. Their header comments list all inputs.
|
||||
|
||||
1. **Host:** give the jail its own System V shared-memory namespace, which
|
||||
PostgreSQL needs, and restart it:
|
||||
|
||||
```sh
|
||||
host# sh deploy/freebsd/host.sh chess
|
||||
```
|
||||
|
||||
2. **Build** the static server on any machine with Go 1.26+, then copy the
|
||||
binary and a checkout of this repository into the jail:
|
||||
|
||||
```sh
|
||||
make server-freebsd # bin/chess-server-freebsd-amd64
|
||||
```
|
||||
|
||||
3. **Jail:** install and configure everything. `OLD_SERVICE` names the rc.d
|
||||
service of the SQLite deployment; `SQLITE_DB` imports its data once.
|
||||
|
||||
```sh
|
||||
jail# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \
|
||||
API_HOST=10.17.89.10 TRUSTED_PROXIES=10.17.89.1 \
|
||||
OLD_SERVICE=chess SQLITE_DB=/usr/local/chess/db/chess.db \
|
||||
sh deploy/freebsd/setup-jail.sh
|
||||
```
|
||||
|
||||
It ends with a health check that requires `"storage":"ok"`.
|
||||
|
||||
4. **Host:** add the location block from
|
||||
[`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf) to
|
||||
the TLS server block, then `nginx -t && service nginx reload`.
|
||||
|
||||
5. **Verify** with the checklist at the end of this document. Keep the old
|
||||
SQLite file until you are satisfied; the import does not modify it.
|
||||
|
||||
`setup-jail.sh` inputs:
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `CHESS_BINARY` | required | New `chess-server` binary |
|
||||
| `API_HOST` | required | Listen address (the jail IP) |
|
||||
| `API_PORT` | `8080` | Listen port |
|
||||
| `TRUSTED_PROXIES` | empty (warns) | nginx address(es) as seen from the jail |
|
||||
| `CHESS_HOME` | account home, else `/usr/local/chess` | Binary and JWT key location |
|
||||
| `LOG_DIR` | `/var/log/chess` | Server log directory |
|
||||
| `SQLITE_DB` | none | v0.11 SQLite database to import into an empty schema |
|
||||
| `OLD_SERVICE` | none | Previous rc.d service to stop and disable |
|
||||
| `SPLIT_PRIVILEGES` | `no` | `yes` for a DML-only runtime role (below) |
|
||||
| `EXTRA_FLAGS` | none | More server flags, e.g. `-max-users 500` |
|
||||
| `PGDATA` | `/var/db/postgres/data18` | PostgreSQL data directory |
|
||||
|
||||
What the script changes, in order:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| Packages | `postgresql18-server`, `postgresql18-client`, `stockfish` (and `sqlite3` for an import) |
|
||||
| PostgreSQL | `initdb` with UTF-8, builtin `C.UTF-8` locale, peer local auth, host auth rejected; data checksums are on by default in 18 |
|
||||
| `pg_hba.conf` | Only `postgres` (all databases) and `chess` (database `chess`), both `local` + `peer`; original kept as `pg_hba.conf.orig` |
|
||||
| `postgresql.auto.conf` | `listen_addresses = ''` (no TCP), socket in `/tmp`, `scram-sha-256` |
|
||||
| Provisioning | [`deploy/postgresql/setup.sql`](../deploy/postgresql/setup.sql) on first run only |
|
||||
| Account | `chess` created if missing (`nologin`, no password); a warning if an existing account has a login shell |
|
||||
| Files | Binary `root:wheel 0555` (previous copy kept as `.prev`); `jwt.key` generated once, `chess:chess 0600`; log directory `chess:chess 0750` |
|
||||
| Schema | `chess-server db init` (as `chess`, or as `chess_owner` in split mode) |
|
||||
| Import | [`migrate-sqlite.sh`](../deploy/postgresql/migrate-sqlite.sh) when `SQLITE_DB` is set and the schema is empty |
|
||||
| Service | [`rc.d/chess_server`](../deploy/freebsd/rc.d/chess_server) and `rc.conf` via `sysrc` |
|
||||
| Log rotation | `/usr/local/etc/newsyslog.conf.d/chess_server.conf`: daily or 1 MB, 7 kept |
|
||||
| Backups | `/usr/local/sbin/chess-backup` via `/usr/local/etc/cron.d/chess-backup`: `pg_dump` at 03:30 as `postgres`, 14 days kept |
|
||||
|
||||
The script was exercised on Linux with FreeBSD-only commands (`pkg`, `sysrc`,
|
||||
`service`, `pw`, `fetch`) stubbed and everything else real: PostgreSQL 18.6,
|
||||
`su`, provisioning in both privilege modes, schema migration, the SQLite
|
||||
import, and the health check of the running server.
|
||||
|
||||
## Accounts and Data Retention
|
||||
|
||||
- Accounts created by registration on the site and by `chess-server db user
|
||||
add` are identical and never expire. Public registration closes at
|
||||
`-max-users` accounts (default 100; `0` removes the limit); the CLI is not
|
||||
limited.
|
||||
- A game belongs to a registered user when they created it while signed in or
|
||||
claimed a slot with their first move. Such games are kept indefinitely and
|
||||
record the player's username at claim time.
|
||||
- Games with no registered player are **deleted 24 hours after their last
|
||||
activity** (creation, move, undo, reconfiguration, or result), from memory and
|
||||
from the database, by the hourly cleanup. `-anonymous-game-ttl` changes the
|
||||
window; `0` keeps them.
|
||||
- Deleting a user removes their sessions; their games keep the claim and name.
|
||||
|
||||
## Reference
|
||||
|
||||
The sections below describe what the scripts configure, for review or manual
|
||||
installation.
|
||||
|
||||
### Jail prerequisites
|
||||
|
||||
PostgreSQL allocates a small System V shared-memory segment even though its
|
||||
main buffers use `mmap`. `host.sh` runs:
|
||||
|
||||
```sh
|
||||
host# bastille config chess set sysvshm new
|
||||
host# bastille restart chess
|
||||
```
|
||||
|
||||
`sysvshm=new` gives the jail an isolated namespace, unlike the older shared
|
||||
`allow.sysvipc`. Without it, `initdb` fails with `could not create shared
|
||||
memory segment`.
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```sh
|
||||
jail# pkg install postgresql18-server postgresql18-client stockfish
|
||||
jail# sysrc postgresql_enable=YES
|
||||
jail# sysrc postgresql_initdb_flags="--encoding=UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8 --auth-local=peer --auth-host=reject"
|
||||
jail# service postgresql initdb
|
||||
jail# service postgresql start
|
||||
jail# su -m postgres -c "psql -X -d postgres" <<'SQL'
|
||||
ALTER SYSTEM SET listen_addresses = '';
|
||||
ALTER SYSTEM SET unix_socket_directories = '/tmp';
|
||||
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
|
||||
SQL
|
||||
jail# service postgresql restart
|
||||
```
|
||||
|
||||
`/var/db/postgres/data18/pg_hba.conf`:
|
||||
|
||||
```
|
||||
# TYPE DATABASE USER METHOD
|
||||
local all postgres peer
|
||||
local chess chess peer
|
||||
```
|
||||
|
||||
### Provisioning and privilege models
|
||||
|
||||
[`setup.sql`](../deploy/postgresql/setup.sql) creates the login role `chess`
|
||||
(no superuser, create-db, create-role, replication, or RLS bypass; 20
|
||||
connections), the `chess` database (`template0`, builtin `C.UTF-8`), and the
|
||||
`chess` schema. It revokes `PUBLIC` access to the database and to schema
|
||||
`public`, and sets role defaults for `search_path`, `statement_timeout`,
|
||||
`lock_timeout`, and `idle_in_transaction_session_timeout`.
|
||||
|
||||
```sh
|
||||
jail# su -m postgres -c 'psql -X -d postgres -f -' < deploy/postgresql/setup.sql
|
||||
```
|
||||
|
||||
| Mode | Schema owner | `chess` role can | Schema migrations |
|
||||
|---|---|---|---|
|
||||
| Owner (default) | `chess` | DML and DDL in schema `chess` only | Automatic at server start |
|
||||
| Split (`-v split=true`, `SPLIT_PRIVILEGES=yes`) | `chess_owner` (NOLOGIN) | `SELECT/INSERT/UPDATE/DELETE` only | `setup-jail.sh`, or manually as `postgres` (below) |
|
||||
|
||||
```sh
|
||||
jail# su -m postgres -c "/usr/local/chess/chess-server db init \
|
||||
-dsn \"dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'\""
|
||||
```
|
||||
|
||||
In split mode the server refuses to start while a migration is pending.
|
||||
|
||||
### The `chess` account
|
||||
|
||||
| Resource | Requirement |
|
||||
|---|---|
|
||||
| Shell/password | `/usr/sbin/nologin`, password disabled |
|
||||
| Home | Holds `chess-server` (`root:wheel 0555`) and `jwt.key` |
|
||||
| JWT key | `chess:chess 0600`, at least 32 bytes; the server refuses group- or world-readable keys |
|
||||
| Log directory | `chess:chess 0750` |
|
||||
| Database | Role `chess` over `/tmp/.s.PGSQL.5432`, database `chess` only |
|
||||
| Network | Listens on the API port (unprivileged, above 1024) |
|
||||
| Engine | `stockfish` in `PATH` (`/usr/local/bin`; set through `chess_server_env`) |
|
||||
|
||||
Administrative CLI commands run as `chess` so peer authentication selects the
|
||||
right role; root may use `su -m` although the account has no shell:
|
||||
|
||||
```sh
|
||||
jail# su -m chess -c '/usr/local/chess/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
|
||||
```
|
||||
|
||||
### JWT signing key
|
||||
|
||||
`setup-jail.sh` generates the key once with `openssl rand -base64 48`. Without
|
||||
a key file, production mode generates a new key at every start and every
|
||||
browser session ends on restart. To rotate, replace the file and restart the
|
||||
service; users then sign in again.
|
||||
|
||||
### Service
|
||||
|
||||
`rc.conf` as written by `setup-jail.sh`:
|
||||
|
||||
```sh
|
||||
chess_server_enable="YES"
|
||||
chess_server_account="chess"
|
||||
chess_server_binary="/usr/local/chess/chess-server"
|
||||
chess_server_logfile="/var/log/chess/chess-server.log"
|
||||
chess_server_env="PATH=/usr/local/bin:/usr/bin:/bin"
|
||||
chess_server_flags="-api-host 10.17.89.10 -api-port 8080 -dsn postgres:///chess?host=/tmp -jwt-secret-file /usr/local/chess/jwt.key -log-level info -trusted-proxies 10.17.89.1"
|
||||
```
|
||||
|
||||
The rc.d script starts `daemon(8)` as root, which writes the supervisor
|
||||
pidfile, drops to `chess`, restarts the server if it exits, and reopens the log
|
||||
on SIGHUP for newsyslog. It declares `REQUIRE: postgresql`. The SQLite-era
|
||||
`-storage-path` flag no longer exists; a server started with it exits with
|
||||
`flag provided but not defined`.
|
||||
|
||||
| Flag | Environment default | Purpose |
|
||||
|---|---|---|
|
||||
| `-dsn` | `CHESS_DSN` | PostgreSQL connection string; empty disables persistence |
|
||||
| `-jwt-secret-file` | `CHESS_JWT_SECRET_FILE` | Stable JWT signing key |
|
||||
| `-trusted-proxies` | | nginx address(es) as seen from the jail |
|
||||
| `-proxy-header` | | Client-IP header set by nginx (default `X-Real-IP`) |
|
||||
| `-max-users` | | Registration cap (default 100, `0` = none) |
|
||||
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
|
||||
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
|
||||
|
||||
### Host nginx
|
||||
|
||||
[`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf) passes the client
|
||||
address in `X-Real-IP` (which nginx overwrites), allows 30-second long-polls,
|
||||
and bounds request bodies. The server keys rate limits on `X-Real-IP` only for
|
||||
connections from `-trusted-proxies`; any other peer is keyed by its own
|
||||
address. Without `-trusted-proxies`, every proxied request shares one bucket.
|
||||
The existing security headers (HSTS, `X-Frame-Options`, CSP with
|
||||
`connect-src 'self'`) are unaffected: the API is same-origin under `/chess/`,
|
||||
and this release does not change the web client.
|
||||
|
||||
### SQLite import
|
||||
|
||||
[`migrate-sqlite.sh`](../deploy/postgresql/migrate-sqlite.sh) reads a
|
||||
schema-version-2 SQLite database (v0.11) and imports, in one transaction, all
|
||||
users as regular accounts with their Argon2id hashes, unexpired sessions,
|
||||
games with claims and player names, results, and moves. It refuses a schema
|
||||
that already has data. Imported games without a registered player follow the
|
||||
24-hour retention. Sessions carry over, but tokens signed with the old
|
||||
per-restart key do not, so users sign in once. Manual invocation:
|
||||
|
||||
```sh
|
||||
jail# su -m postgres -c 'sh /path/to/migrate-sqlite.sh /tmp/legacy.db dbname=chess'
|
||||
```
|
||||
|
||||
### Upgrades, backups, and restores
|
||||
|
||||
- **Upgrade:** build the new binary and rerun `setup-jail.sh` with the same
|
||||
inputs (omit `SQLITE_DB`). It keeps the previous binary as
|
||||
`chess-server.prev`, migrates the schema, and restarts the service. To roll
|
||||
back a release without a schema change, copy `.prev` back and restart.
|
||||
- **Migrations** are transactional and serialized by an advisory lock. A
|
||||
server refuses to start against a schema newer than it supports.
|
||||
- **Backups:** `chess-backup` writes `pg_dump --format=custom` files to
|
||||
`/var/db/postgres/backups` nightly and removes those older than 14 days. Copy
|
||||
them off the jail.
|
||||
- **Restore** into a freshly provisioned database:
|
||||
`su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'`
|
||||
(`--role=chess_owner` in split mode).
|
||||
- **PostgreSQL restarts:** the server retries in-flight writes across a brief
|
||||
outage. A write that still fails marks storage `degraded` in `/health`; live
|
||||
games continue in memory, but durable history stops until the server is
|
||||
restarted.
|
||||
|
||||
### Verification checklist
|
||||
|
||||
```sh
|
||||
jail# su -m chess -c 'psql -X -d chess -Atc "SHOW search_path"' # chess
|
||||
jail# su -m chess -c 'psql -X -d postgres -c "select 1"' # rejected by pg_hba
|
||||
jail# sockstat -4 -6 -l | grep postgres # no TCP listener
|
||||
jail# service chess_server status
|
||||
jail# fetch -qo - http://10.17.89.10:8080/health # "storage":"ok"
|
||||
host# curl -s https://lixen.com/chess/health # through nginx
|
||||
```
|
||||
+85
-49
@@ -4,7 +4,7 @@
|
||||
|
||||
- Go 1.26+
|
||||
- Stockfish in PATH
|
||||
- SQLite3
|
||||
- PostgreSQL 18 (server for persistence; `psql` for the shell test suites)
|
||||
- Git
|
||||
- curl, jq (for testing)
|
||||
|
||||
@@ -26,12 +26,17 @@ go build ./cmd/chess-client-cli
|
||||
- `-web-host`: Web UI server host (default: localhost)
|
||||
- `-web-port`: Web UI server port (default: 9090)
|
||||
- `-dev`: Development mode with relaxed rate limits and fixed JWT secret
|
||||
- `-storage-path`: SQLite database file path (enables persistence and authentication)
|
||||
- `-dsn`: PostgreSQL connection string (default `$CHESS_DSN`); enables persistence and authentication
|
||||
- `-jwt-secret-file`: Stable JWT signing key file, mode 0600, at least 32 bytes (default `$CHESS_JWT_SECRET_FILE`)
|
||||
- `-trusted-proxies`: Comma-separated reverse-proxy IPs/CIDRs whose `-proxy-header` is trusted
|
||||
- `-proxy-header`: Client-IP header from a trusted proxy (default `X-Real-IP`)
|
||||
- `-pid`: PID file path for process tracking
|
||||
- `-pid-lock`: Enable exclusive locking (requires -pid)
|
||||
- `-log-level`: `debug`, `info`, `warn`, or `error` (default: `info`)
|
||||
- `-log-http`: Enable API and web request logs (default: `true`)
|
||||
- `-finished-game-ttl`: How long terminal games stay in memory (default: `1h`; `0` disables eviction)
|
||||
- `-anonymous-game-ttl`: How long games without a registered player survive after their last activity, in memory and in the database (default: `24h`; `0` keeps them)
|
||||
- `-max-users`: Accounts at which public registration closes (default: `100`; `0` = no limit; the CLI is not limited)
|
||||
- `-web-api-url`: Browser-visible API origin for the embedded web client; useful when its public origin differs from the listen address
|
||||
|
||||
### Modes
|
||||
@@ -40,84 +45,107 @@ go build ./cmd/chess-client-cli
|
||||
./chess-server
|
||||
|
||||
# With persistence and authentication
|
||||
./chess-server -storage-path ./db/chess.db
|
||||
./chess-server -dsn 'dbname=chess'
|
||||
|
||||
# Development with all features
|
||||
./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -serve
|
||||
./chess-server -dev -dsn 'dbname=chess' -pid /tmp/chess-server.pid -serve
|
||||
|
||||
# Detailed persistence, engine-queue, cleanup, and request logs
|
||||
./chess-server -dev -storage-path chess.db -serve -log-level debug -log-http=true
|
||||
./chess-server -dev -dsn 'dbname=chess' -serve -log-level debug -log-http=true
|
||||
|
||||
# Behind a reverse proxy that sets X-Real-IP
|
||||
./chess-server -dsn 'dbname=chess' -trusted-proxies 10.0.0.1
|
||||
|
||||
# Web UI is public at one origin while the API is exposed at another
|
||||
./chess-server -serve -web-api-url https://api.example.test
|
||||
|
||||
# Initialize database with user tables
|
||||
./chess-server db init -path chess.db
|
||||
# Create or migrate the schema
|
||||
./chess-server db init -dsn 'dbname=chess'
|
||||
```
|
||||
|
||||
### Local PostgreSQL
|
||||
|
||||
Any PostgreSQL 18 works for development. With a local server and peer
|
||||
authentication for your OS user:
|
||||
|
||||
```bash
|
||||
sudo -u postgres createuser "$USER"
|
||||
sudo -u postgres createdb -O "$USER" chess
|
||||
export CHESS_DSN='dbname=chess'
|
||||
```
|
||||
|
||||
Production provisioning (roles, schema, `pg_hba.conf`) is in
|
||||
[deployment.md](./deployment.md).
|
||||
|
||||
## Database Management
|
||||
|
||||
All `db` subcommands take `-dsn`, defaulting to `$CHESS_DSN`; the examples
|
||||
below assume it is exported. Commands other than `init` and `delete` refuse to
|
||||
run against a missing or outdated schema.
|
||||
|
||||
### Schema Initialization
|
||||
```bash
|
||||
# Create all tables (users, games, moves)
|
||||
./chess-server db init -path chess.db
|
||||
# Create or migrate all tables; safe to repeat
|
||||
./chess-server db init
|
||||
```
|
||||
|
||||
### User Management CLI
|
||||
```bash
|
||||
# Add user with password
|
||||
./chess-server db user add -path chess.db -username alice -password SecurePass123
|
||||
./chess-server db user add -username alice -password SecurePass123
|
||||
|
||||
# Add user with email
|
||||
./chess-server db user add -path chess.db -username bob -email bob@example.com -password BobPass456
|
||||
./chess-server db user add -username bob -email bob@example.com -password BobPass456
|
||||
|
||||
# Interactive password input
|
||||
./chess-server db user add -path chess.db -username charlie -interactive
|
||||
./chess-server db user add -username charlie -interactive
|
||||
|
||||
# List all users
|
||||
./chess-server db user list -path chess.db
|
||||
./chess-server db user list
|
||||
|
||||
# Update password
|
||||
./chess-server db user set-password -path chess.db -username alice -password NewPass789
|
||||
./chess-server db user set-password -username alice -password NewPass789
|
||||
|
||||
# Update email
|
||||
./chess-server db user set-email -path chess.db -username alice -email newemail@example.com
|
||||
./chess-server db user set-email -username alice -email newemail@example.com
|
||||
|
||||
# Update username
|
||||
./chess-server db user set-username -path chess.db -current alice -new alice2
|
||||
./chess-server db user set-username -current alice -new alice2
|
||||
|
||||
# Import with existing Argon2 hash
|
||||
./chess-server db user set-hash -path chess.db -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...'
|
||||
./chess-server db user set-hash -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...'
|
||||
|
||||
# Delete user
|
||||
./chess-server db user delete -path chess.db -username alice
|
||||
./chess-server db user delete -username alice
|
||||
```
|
||||
|
||||
### Game Query CLI
|
||||
```bash
|
||||
# Query all games
|
||||
./chess-server db query -path chess.db -gameId "*"
|
||||
./chess-server db query -gameId "*"
|
||||
|
||||
# Query games for specific user
|
||||
./chess-server db query -path chess.db -playerId "550e8400-e29b-41d4-a716-446655440000"
|
||||
./chess-server db query -playerId "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
# Query specific game
|
||||
./chess-server db query -path chess.db -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef"
|
||||
./chess-server db query -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef"
|
||||
|
||||
# Delete database (destructive)
|
||||
./chess-server db delete -path chess.db
|
||||
# Drop every chess table in the DSN's search_path (destructive)
|
||||
./chess-server db delete -confirm
|
||||
```
|
||||
|
||||
## Authentication Configuration
|
||||
|
||||
### JWT Secret Management
|
||||
- **Production**: A cryptographically secure 32-byte secret is generated on
|
||||
startup. This intentionally invalidates JWTs after a restart even though the
|
||||
SQLite session rows remain; configuring a stable deployment secret is tracked
|
||||
in `doc/todo.md`.
|
||||
- **Development** (`-dev`): Fixed secret for testing consistency
|
||||
- **Sessions**: Stored for 7 days and renewed on each login; effective token
|
||||
lifetime is also bounded by signing-key rotation
|
||||
- **Production**: Provide `-jwt-secret-file` so tokens survive restarts. The
|
||||
file must be a regular file with no group/other permissions and hold at
|
||||
least 32 bytes (`openssl rand -base64 48`). Without it, a random key is
|
||||
generated per process and every token is invalidated on restart, although
|
||||
the session rows remain.
|
||||
- **Development** (`-dev`): Fixed secret for testing consistency when no key file is given
|
||||
- **Scope**: Tokens are issued for `chess-server` / `chess-api`, carry only the
|
||||
subject and session ID, and are accepted only while their session row exists
|
||||
- **Sessions**: Stored for 7 days and renewed on each login
|
||||
|
||||
### Password Requirements
|
||||
- Minimum 8 characters
|
||||
@@ -160,7 +188,7 @@ chess/
|
||||
│ │ ├── service.go # Core service
|
||||
│ │ ├── game.go # Game operations
|
||||
│ │ └── user.go # User and auth operations
|
||||
│ └── storage/ # SQLite persistence
|
||||
│ └── storage/ # PostgreSQL persistence (pgtest: per-test schemas)
|
||||
│ ├── storage.go # Async writer for games
|
||||
│ ├── game.go # Game persistence
|
||||
│ ├── user.go # User persistence (synchronous)
|
||||
@@ -180,13 +208,15 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
|
||||
# User authentication and database tests
|
||||
./test/test-db.sh
|
||||
|
||||
# Run test server with sample users
|
||||
./test/test-db-server.sh
|
||||
# Run test server with sample users (needs a disposable CHESS_TEST_DSN)
|
||||
./test/run-test-server.sh
|
||||
|
||||
# Test real-time game updates via long-polling
|
||||
./test/test-longpoll.sh
|
||||
|
||||
# Unit, migration, and persistence tests
|
||||
# Unit, migration, and persistence tests; database tests are skipped unless
|
||||
# CHESS_TEST_DSN names a database the test role may create schemas in
|
||||
export CHESS_TEST_DSN='postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable'
|
||||
go test ./...
|
||||
|
||||
# Concurrency checks for the state/persistence boundary
|
||||
@@ -201,8 +231,11 @@ go test -race ./internal/server/storage ./internal/server/service
|
||||
- Queue capacity: 100 (internal/processor/queue.go)
|
||||
- Min search time: 100ms (internal/processor/processor.go)
|
||||
- Write queue: 1000 operations (internal/server/storage/storage.go)
|
||||
- DB connections: 8 max, 4 idle (internal/server/storage/storage.go)
|
||||
- DB connections: 10 max, 5 idle (internal/server/storage/storage.go)
|
||||
- DB operation deadline: 5 seconds; write transactions 10 seconds
|
||||
- Transient write retries: 6 attempts, 100 ms doubling backoff
|
||||
- JWT expiration: 7 days (internal/service/user.go)
|
||||
- Concurrent Argon2id derivations: 4, five-second wait (internal/service/user.go)
|
||||
- Long-poll timeout: 30 seconds (internal/server/service/waiter.go)
|
||||
- Long-poll channel buffer: 1 (internal/service/waiter.go)
|
||||
|
||||
@@ -213,20 +246,21 @@ go test -race ./internal/server/storage ./internal/server/service
|
||||
- Hash algorithm: Argon2id (memory-hard, side-channel resistant)
|
||||
|
||||
### Storage Configuration
|
||||
- WAL mode and NORMAL synchronous mode enabled on every connection
|
||||
- Foreign key constraints and a five-second busy timeout enabled on every connection
|
||||
- PostgreSQL via pgx; schema chosen by `search_path`; migrations run at startup
|
||||
- Async write pattern for games; shutdown drains every accepted write
|
||||
- Replay reads wait for prior queued writes and use one read transaction
|
||||
- Transient failures before COMMIT are retried; others degrade storage
|
||||
- Replay reads wait for prior queued writes and use one REPEATABLE READ transaction
|
||||
- Synchronous writes for user operations (data consistency)
|
||||
- Registration capacity/eviction, user creation, and initial session are atomic
|
||||
- A full queue or write failure degrades to memory-only and is visible in logs and `/health`
|
||||
- Case-insensitive collation for usernames and emails
|
||||
- Registration limit check, user creation, and initial session are atomic
|
||||
- Games without a registered player are deleted 24 hours after their last activity
|
||||
- A full queue or unrecovered write failure degrades to memory-only and is visible in logs and `/health`
|
||||
- Usernames and emails stored lowercase with unique constraints
|
||||
|
||||
### Rate Limiting Configuration
|
||||
- General endpoints: 10 req/s (20 in dev mode)
|
||||
- User registration: 5 req/min
|
||||
- User login: 10 req/min
|
||||
- Rate limit key: IP address from X-Forwarded-For or connection
|
||||
- Rate limit key: `-proxy-header` (default `X-Real-IP`) from `-trusted-proxies`, otherwise the TCP peer
|
||||
|
||||
### PID Management
|
||||
- Singleton enforcement requires same PID file path
|
||||
@@ -247,10 +281,11 @@ go test -race ./internal/server/storage ./internal/server/service
|
||||
|
||||
### Authentication Security
|
||||
- Passwords hashed with Argon2id before storage
|
||||
- JWT tokens signed with HS256
|
||||
- Constant-time password comparison
|
||||
- Case-insensitive matching prevents user enumeration
|
||||
- JWT tokens signed with HS256 and scoped by issuer/audience
|
||||
- Constant-time password comparison; unknown accounts verify a dummy hash
|
||||
- Case-insensitive matching prevents duplicate accounts
|
||||
- Rate limiting on auth endpoints prevents brute force
|
||||
- Argon2id concurrency is bounded to cap memory under load
|
||||
|
||||
### Input Validation
|
||||
- All user inputs validated and sanitized
|
||||
@@ -261,18 +296,19 @@ go test -race ./internal/server/storage ./internal/server/service
|
||||
### Session Management
|
||||
- JWT tokens expire after 7 days
|
||||
- No token refresh mechanism (re-login required)
|
||||
- Tokens include minimal claims (user ID, username, email)
|
||||
- Secret rotates on server restart (except dev mode)
|
||||
- Tokens carry only the user ID and session ID
|
||||
- Key is stable with `-jwt-secret-file`; otherwise it rotates on restart (fixed in dev mode)
|
||||
|
||||
## Limitations
|
||||
|
||||
- JWT tokens don't support refresh (must re-login after expiry)
|
||||
- User deletion doesn't cascade to games (games remain with player IDs)
|
||||
- User deletion doesn't cascade to games (games keep player and claim IDs)
|
||||
- Games played without a registered player are deleted 24 hours after their last activity
|
||||
- No password recovery mechanism
|
||||
- No email verification for registration
|
||||
- Fixed worker pool size for engine calculations
|
||||
- No push-based game updates (30-second long-polling is used)
|
||||
- Live games are not rehydrated after restart; persisted games are currently replay-only
|
||||
- Database history has no automatic retention policy
|
||||
- Curated-game metadata and replay controls are deferred to [Replay Implementation Tasks](./todo.md)
|
||||
- Replay UI, PGN export, and curated games are planned in [Replay Plan](./todo.md)
|
||||
- REST API only
|
||||
+133
-240
@@ -1,266 +1,159 @@
|
||||
# Replay Implementation Tasks
|
||||
# Game Replay Plan
|
||||
|
||||
This plan covers the remaining work needed for first-class replay in the web
|
||||
and CLI clients, durable player-game browsing, and a curated archive of famous
|
||||
games. The persistence/API foundation completed by the database audit is listed
|
||||
first so later work does not duplicate or bypass it.
|
||||
Target experience: a user registers, signs in, plays, and later opens **My
|
||||
games** to pick any finished or unfinished game. Replay runs automatically or
|
||||
one ply at a time for either side, moves backward and forward freely, and
|
||||
exports the FEN or PGN of the position at the current ply, in the spirit of the
|
||||
chess.com analysis viewer.
|
||||
|
||||
## Foundation Available Now
|
||||
This plan starts from the PostgreSQL release and splits the remaining work
|
||||
into phases that each ship and test on their own.
|
||||
|
||||
- [x] Store terminal `result` and `end_time_utc` on each game.
|
||||
- [x] Persist `white_claimed_by` and `black_claimed_by`, including claims made
|
||||
on the first valid move after game creation.
|
||||
- [x] Commit each move, first-move claim, and move-caused terminal result in one
|
||||
SQLite transaction.
|
||||
- [x] Rewind moves and clear a stale terminal result/end time in one transaction.
|
||||
- [x] Return ordered UCI moves with `fenAfterMove` through
|
||||
`GET /api/v1/games/{gameId}/history`.
|
||||
- [x] Return bounded pages through authenticated
|
||||
`GET /api/v1/users/me/games?limit=&offset=`.
|
||||
- [x] Provide matching Go client DTOs and methods (`GetGameHistory`,
|
||||
`GetMyGames`) without prematurely adding CLI presentation.
|
||||
- [x] Evict terminal games from memory after a configurable TTL while retaining
|
||||
durable rows and moves.
|
||||
- [x] Add an async-write barrier and a single SQLite read snapshot for immediate,
|
||||
internally consistent replay reads.
|
||||
- [x] Configure browser API origin through `/config`, with `/chess` fallback for
|
||||
the static deployment at `lixen.com/projects/chess/`.
|
||||
- [x] Add debug-level persistence, cleanup, engine queue, and lifecycle logging.
|
||||
## Foundation in Place
|
||||
|
||||
## Decisions Required Before Replay UI Work
|
||||
- [x] PostgreSQL 18 schema with durable games, ordered moves, results, end
|
||||
times, and slot claims; transactional migrations (`schema_version`).
|
||||
- [x] Each accepted move, first-move claim, and move-caused result commits in
|
||||
one transaction; undo removes moves and clears the result atomically.
|
||||
- [x] `GET /api/v1/games/{id}/history`: initial FEN plus UCI and resulting FEN
|
||||
for every ply, read in one REPEATABLE READ snapshot after pending writes.
|
||||
- [x] `GET /api/v1/users/me/games`: claimed games, newest first, with move
|
||||
count and final FEN (thumbnail) from a primary-key probe per game.
|
||||
- [x] Claims survive player reconfiguration and terminal-game eviction; the
|
||||
listing indexes are ordered for keyset pagination.
|
||||
- [x] Stable JWT key (`-jwt-secret-file`), so a returning user's session
|
||||
survives server restarts.
|
||||
- [x] Uniform, non-expiring accounts for site and CLI users (D1); 24-hour
|
||||
retention of games without a registered player (D1).
|
||||
- [x] Player-name snapshots on claim, returned in history and listings (D4).
|
||||
- [x] Scripted FreeBSD jail deployment (`deploy/`).
|
||||
- [x] Go client DTOs (`GetGameHistory`, `GetMyGames`) including `finalFen`.
|
||||
|
||||
| Decision | Current behavior | Decision needed |
|
||||
## Decisions
|
||||
|
||||
| # | Decision | Status |
|
||||
|---|---|---|
|
||||
| History visibility | Public to anyone with a game UUID, like live game reads | Keep public, make games private by default, or add per-game visibility |
|
||||
| Live mutation authorization | Configure, undo, computer-trigger, and unload remain UUID-based; claimed slots protect human moves only | Define owner/opponent/spectator permissions before replay and archive UI expose more game discovery |
|
||||
| Database retention | Indefinite; only terminal in-memory state is evicted | Retention by account type, archive status, age, or explicit deletion |
|
||||
| Delete semantics | `DELETE /games/{id}` unloads memory but retains history | Name it “close/unload,” or add a separate authorized durable delete |
|
||||
| Durability guarantee | Gameplay continues after a write failure; health becomes degraded | Keep best-effort, acknowledge writes, retry with an outbox, or fail gameplay closed |
|
||||
| Result model | `white_wins`, `black_wins`, `draw`, `stalemate` | Keep compatibility or split outcome (`1-0`, `0-1`, `1/2-1/2`) from termination reason |
|
||||
| Archived-game owner | Not created | Protected system/demo user, separate archive owner table, or ownerless source records |
|
||||
| Replay notation | UCI plus authoritative FEN after each move | Add SAN and canonical PGN at import/write time or derive them on read |
|
||||
| D1 | Account lifetime | **Done.** Site registrations and CLI-created accounts are identical and never expire; public registration closes at `-max-users`. Games without a registered player are deleted 24 hours after their last activity (`-anonymous-game-ttl`). |
|
||||
| D2 | History visibility | **Decided: shareable by game ID.** The history endpoint stays public to holders of the 122-bit random ID; IDs are never listed publicly and the list endpoint stays authenticated. |
|
||||
| D3 | Where SAN/PGN is produced | **Decided: server-side Go** (R1), derived on read from the initial FEN and the UCI line: no schema change, one implementation for web and CLI, no third-party script under the CSP. |
|
||||
| D4 | Player names | **Done.** `white_name`/`black_name` snapshot the claimant's username in the claim transaction and survive renames and deletions; exposed as `players.*.name`. |
|
||||
| D5 | Draw rules | Open. Recommendation: detect insufficient material, threefold repetition, and the 50/75-move rules in the R1 core; add resign/draw offers later. PGN `Result` is `*` for unterminated games. |
|
||||
| D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. |
|
||||
| D7 | List pagination | Open. Recommendation: keyset cursor on `(start_time_utc, game_id)`, keeping offset during v1; the claim indexes are already ordered for it. |
|
||||
|
||||
Record these choices in an ADR before changing the v1 response contract. Until
|
||||
privacy is decided, do not add searchable public player-game indexes or expose
|
||||
usernames in public history.
|
||||
## Phase R1 — Notation Core (server, no API change)
|
||||
|
||||
## Phase 1 — Complete the Durable Game Model
|
||||
A small, dependency-free chess core in Go (`internal/server/chess`), used by
|
||||
replay only; Stockfish remains the move validator for live play.
|
||||
|
||||
### Results and termination
|
||||
- [ ] Board model with FEN parse/serialize (extend `internal/server/board`).
|
||||
- [ ] Legal move generation: pseudo-legal moves plus king-safety filtering,
|
||||
castling rights and paths, en passant, promotion.
|
||||
- [ ] SAN encode (disambiguation by file, rank, or both; `+`/`#`; `O-O`,
|
||||
`O-O-O`; `=Q`) and SAN decode for later PGN import.
|
||||
- [ ] PGN writer: Seven Tag Roster (White/Black from the name snapshots, else
|
||||
"Anonymous" or "Stockfish level N"), `SetUp`/`FEN` tags for custom starts,
|
||||
`Termination`, 80-column movetext, `Result` from the stored outcome.
|
||||
- [ ] Draw-rule helpers (D5) and an integrity check that replays the stored UCI
|
||||
line and compares each generated FEN with the stored `fenAfterMove`.
|
||||
|
||||
- [ ] Represent outcome separately from termination reason. Candidate fields:
|
||||
`outcome`, `termination`, and optional `result_detail`.
|
||||
- [ ] Detect and persist all supported draw paths, not only stalemate:
|
||||
insufficient material, repetition, fifty/seventy-five-move rule, and agreed
|
||||
draw if that interaction is added.
|
||||
- [ ] Define behavior for resignation, timeout, abandonment, engine failure,
|
||||
and administrative termination.
|
||||
- [ ] Add constraints covering valid combinations: an end time requires a
|
||||
terminal outcome; an ongoing game has neither.
|
||||
- [ ] Decide whether undoing a finished rated/player game is allowed. If yes,
|
||||
preserve an audit event rather than silently rewriting official history.
|
||||
Test gate:
|
||||
|
||||
### Stable participant metadata
|
||||
- [ ] Perft node counts for the standard CPW positions (start, Kiwipete, and
|
||||
positions 3–6) to depth 4, which exercises castling, en passant, promotion,
|
||||
and pins.
|
||||
- [ ] Property tests over random legal games: SAN decode(encode(m)) == m; the
|
||||
generated FEN sequence is identical to one produced by Stockfish's `d`
|
||||
command for the same line.
|
||||
- [ ] Fuzz targets for FEN parsing, SAN decoding, and PGN writing (no panics,
|
||||
bounded output).
|
||||
- [ ] Integrity check run over every stored game in a production-shaped copy.
|
||||
|
||||
- [ ] Snapshot display names at game start/end so replay remains readable after
|
||||
a user rename or temporary-account deletion.
|
||||
- [ ] Separate historical participant identity from mutable controller config.
|
||||
Changing a human slot to a computer must never remove the user's game link.
|
||||
- [ ] Decide whether anonymous players receive a durable pseudonym, remain
|
||||
unnamed, or are excluded from archive browsing.
|
||||
- [ ] Add optional clocks/time-control metadata before timeout results are
|
||||
supported.
|
||||
## Phase R2 — Replay API
|
||||
|
||||
### Notation and integrity
|
||||
- [ ] Implement D7 (keyset cursor) once decided.
|
||||
- [ ] Additive history fields: `san` per move, `outcome`/`termination`, and
|
||||
`pgnResult`; keep existing fields unchanged.
|
||||
- [ ] `GET /api/v1/games/{id}/pgn?ply=N`: `application/x-chess-pgn`,
|
||||
`Content-Disposition: attachment; filename="chess-<date>-<id8>.pgn"`,
|
||||
moves 1..N (default all). The FEN at any ply is already in the history.
|
||||
- [ ] `ETag` and `Cache-Control: private, max-age` for terminal games;
|
||||
`If-None-Match` returns 304.
|
||||
- [ ] Filters on the list endpoint: result, color, ongoing/finished.
|
||||
- [ ] Tests: handler tests against PostgreSQL (pgtest), golden PGN files for
|
||||
mate, stalemate, promotion, castling, en passant, custom FEN, and an ongoing
|
||||
game; shell-suite coverage for authorization (another user's list, expired
|
||||
session) and cursor stability under concurrent inserts.
|
||||
|
||||
- [ ] Add SAN per ply and canonical PGN, or add a deterministic backend
|
||||
converter from the stored initial FEN/UCI line.
|
||||
- [ ] Validate that `move_number`, `player_color`, FEN side-to-move, and the
|
||||
previous position form one legal continuous line.
|
||||
- [ ] Add a stored content hash for import idempotency and corruption checks.
|
||||
- [ ] Add a repair/audit CLI command that reports broken game rows without
|
||||
mutating them; make repair an explicit separate operation.
|
||||
- [ ] Define a schema-migration policy beyond v2, including forward-version
|
||||
rejection, backup instructions, and rollback limitations.
|
||||
## Phase R3 — Web Replay UI
|
||||
|
||||
## Phase 2 — Replay and Library APIs
|
||||
Plain JavaScript in the embedded client, no framework.
|
||||
|
||||
### Player games
|
||||
- [ ] **My games** button beside the account indicator (authenticated only)
|
||||
opening a panel: mini-board from `finalFen`, result, date, opponent or
|
||||
engine level, move count; loading, empty, error, and "load more" states.
|
||||
- [ ] Replay mode entered from the panel or a deep link
|
||||
`?replay=<gameId>&ply=<n>`; `history.pushState` so browser back/forward
|
||||
restores the list and the ply.
|
||||
- [ ] Controls: first, previous, next, last; keyboard `←` `→` `Home` `End`;
|
||||
clickable SAN move list; autoplay with speed selector and pause; board flip;
|
||||
current ply announced through an `aria-live` region.
|
||||
- [ ] Position rendering assigns the stored FEN for the ply; the browser never
|
||||
computes moves.
|
||||
- [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched
|
||||
from R2 as a `Blob`, saved through a temporary `<a download>`).
|
||||
- [ ] While replaying: stop live long-polling, disable move, undo, new-game,
|
||||
and player-configuration controls; **Back to live game** restores them.
|
||||
|
||||
- [ ] Add filters to the authenticated list: `status`, `result`, color, opponent
|
||||
type, and date range.
|
||||
- [ ] Replace offset pagination with a stable `(start_time_utc, game_id)` cursor
|
||||
before the table grows large; retain v1 offset parameters during migration.
|
||||
- [ ] Return a compact display label/opponent summary so clients do not recreate
|
||||
association logic.
|
||||
- [ ] Define whether an authenticated user may list a game merely created for
|
||||
their random player ID versus one explicitly claimed by them.
|
||||
- [ ] Add authorization tests for expired/deleted sessions and attempts to list
|
||||
another user's games.
|
||||
Constraints from the host's security headers:
|
||||
|
||||
### Replay payload
|
||||
- `script-src 'self' 'wasm-unsafe-eval'`: all code in `app.js`; no inline
|
||||
scripts, `on*` attributes, `eval`, or `new Function`.
|
||||
- `style-src 'self' 'unsafe-inline'`: styles belong in `style.css`; state
|
||||
classes instead of inline style strings.
|
||||
- `img-src 'self' data:`: pieces remain Unicode glyphs or `data:` SVG.
|
||||
- `connect-src 'self'`: all requests go to the same origin under `/chess/`
|
||||
(the existing `/config` fallback); no CDN or cross-origin API.
|
||||
- `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN`: the iframe host
|
||||
page must stay on the same origin. Downloads from `blob:` URLs and
|
||||
`navigator.clipboard` work in a same-origin frame.
|
||||
|
||||
- [ ] Version the history payload before adding annotations, evaluations,
|
||||
comments, variations, clocks, or PGN tags.
|
||||
- [ ] Include a canonical final FEN and normalized outcome/termination fields.
|
||||
- [ ] Decide whether long games return one payload or paged/chunked moves.
|
||||
- [ ] Add `ETag`/`If-None-Match` for immutable finished histories.
|
||||
- [ ] Add a downloadable PGN response with correct `Content-Type` and filename.
|
||||
- [ ] Return an explicit “ongoing/incomplete” marker when history is requested
|
||||
before a terminal result.
|
||||
Test gate:
|
||||
|
||||
### Live-game restoration
|
||||
- [ ] Playwright (Chromium) end-to-end against server and PostgreSQL: play a
|
||||
short game, open My games, step backward and forward, autoplay to the end,
|
||||
and compare exported FEN/PGN with the API.
|
||||
- [ ] Keyboard-only navigation and screen-reader labels; reduced-motion
|
||||
disables autoplay animation.
|
||||
- [ ] Deep link works beneath `/projects/chess/` with the API at `/chess`.
|
||||
|
||||
- [ ] Decide whether a server restart should make unfinished games playable or
|
||||
replay-only.
|
||||
- [ ] If play must resume, load the last persisted FEN, next turn, player config,
|
||||
claims, and move list into memory at startup.
|
||||
- [ ] Mark games interrupted in `pending` state as recoverable `stuck` or
|
||||
`ongoing`; never re-submit an engine task blindly.
|
||||
- [ ] Define reconciliation when the service previously entered degraded mode
|
||||
and memory contains moves absent from SQLite.
|
||||
## Phase R4 — CLI and WASM Replay (optional)
|
||||
|
||||
## Phase 3 — Curated Famous-Game Archive
|
||||
- [ ] `games` (list with pagination), `replay <gameId|index>`, `next`, `prev`,
|
||||
`first`, `last`, `goto <ply>`, `auto <ms>`, `fen`, `pgn save <path>`.
|
||||
- [ ] Replay state is separate from the live session: no polling, moves, undo,
|
||||
or configuration while replaying.
|
||||
|
||||
### Schema and ownership
|
||||
## Phase R5 — Curated Archive (later)
|
||||
|
||||
- [ ] Add a game origin such as `player`, `curated`, or `imported`.
|
||||
- [ ] Add searchable archive metadata: title, event, site, event date, round,
|
||||
white/black display names, Elo values, ECO/opening, source URL, source license,
|
||||
attribution text, and import timestamp.
|
||||
- [ ] Add publication state, featured flag, and explicit featured rank/order.
|
||||
- [ ] Create a protected demo/system identity only if ownership remains tied to
|
||||
users. It must not consume temporary-user capacity, expire, authenticate, or
|
||||
be evicted/deleted through normal user tools.
|
||||
- [ ] Prefer a separate protected archive owner over credentials embedded in
|
||||
seed scripts.
|
||||
- [ ] Add only indexes backed by actual archive queries; confirm each with
|
||||
`EXPLAIN QUERY PLAN` and a representative data volume.
|
||||
- [ ] Schema: game origin (`player`, `curated`), archive metadata (event, site,
|
||||
date, round, names, Elo, ECO, source URL, license, attribution), publication
|
||||
state, featured order; no user ownership.
|
||||
- [ ] `chess-server db archive import <pgn|dir>` using the R1 SAN decoder:
|
||||
validate every move, import each game in one transaction, idempotent by
|
||||
content hash, dry-run and per-file summary.
|
||||
- [ ] Public, bounded, cacheable list endpoint; replay through the same
|
||||
history/PGN endpoints.
|
||||
- [ ] Verify redistribution rights for every bundled collection.
|
||||
|
||||
### Import pipeline
|
||||
## Operations Carried Forward
|
||||
|
||||
- [ ] Add `chess-server db archive import` for one PGN or a directory.
|
||||
- [ ] Parse PGN tags, comments, NAGs, and variations deliberately; document
|
||||
which are preserved and which are discarded in the first version.
|
||||
- [ ] Validate every main-line move from its initial position and generate the
|
||||
authoritative FEN sequence before opening the transaction.
|
||||
- [ ] Import a game and all moves in one transaction.
|
||||
- [ ] Make repeated imports idempotent by source key/content hash.
|
||||
- [ ] Add dry-run, structured error output, per-file summary, and all-or-nothing
|
||||
versus continue-on-error modes.
|
||||
- [ ] Preserve source attribution and verify redistribution rights for every
|
||||
bundled collection.
|
||||
- [ ] Seed a small, reviewed fixture set in tests; keep large archives outside
|
||||
the executable and repository unless licensing and binary size are accepted.
|
||||
|
||||
### Archive API
|
||||
|
||||
- [ ] Add a public, bounded curated list endpoint with stable sorting.
|
||||
- [ ] Add exact filters required by the UI (featured, player name, event, year,
|
||||
ECO); do not expose an unconstrained database query API.
|
||||
- [ ] Reuse the same history representation for player and curated games.
|
||||
- [ ] Cache immutable curated list/history responses and invalidate only on
|
||||
archive administration.
|
||||
|
||||
## Phase 4 — CLI Replay Experience
|
||||
|
||||
- [ ] Add `games`/`games mine` to call `GetMyGames`, show pagination, result,
|
||||
colors, opponent/controller, date, and move count.
|
||||
- [ ] Add `games featured` after the curated endpoint exists.
|
||||
- [ ] Add `replay <gameId>` and allow selection from a prior list result.
|
||||
- [ ] Render the initial FEN before ply 1; never assume the standard start.
|
||||
- [ ] Add next/previous/start/end navigation, move-number jump, and optional
|
||||
autoplay speed.
|
||||
- [ ] Display UCI initially and SAN once the backend contract supplies it.
|
||||
- [ ] Clearly separate replay state from live session state: replay commands
|
||||
must not poll, move, undo, configure, or delete the live game.
|
||||
- [ ] Add `pgn save <path>` after the PGN endpoint is defined.
|
||||
- [ ] Cover empty lists, ongoing histories, custom FEN, malformed/incomplete
|
||||
history, expired auth, server restart, and deleted live-memory state.
|
||||
|
||||
## Phase 5 — Web Replay Experience
|
||||
|
||||
- [ ] Add “My games” for authenticated users and a separate “Classic games”
|
||||
collection available without login.
|
||||
- [ ] Build accessible loading, empty, pagination, and error states.
|
||||
- [ ] Add a replay route/deep link, for example `?replay=<gameId>`, that works
|
||||
beneath `/projects/chess/` and does not assume the API shares that path.
|
||||
- [ ] Initialize from `initialFen`; step by assigning the stored
|
||||
`fenAfterMove`, not by replaying moves through a browser chess engine.
|
||||
- [ ] Add previous/next/start/end buttons, move-list selection, keyboard
|
||||
controls, autoplay speed, pause, and current-ply announcement.
|
||||
- [ ] Disable move, computer-trigger, undo, and player-configuration actions in
|
||||
replay mode.
|
||||
- [ ] Stop live long-polling when replay mode begins and restore it only when a
|
||||
live game is explicitly reopened.
|
||||
- [ ] Show result, termination, players, date/event, source attribution, and
|
||||
custom-start notice.
|
||||
- [ ] Make browser back/forward restore list filters and replay ply.
|
||||
- [ ] Test both embedded `/config` and the deployed `/chess` fallback, including
|
||||
CORS and reverse-proxy headers.
|
||||
- [ ] Add responsive and accessibility checks for board orientation, focus,
|
||||
screen-reader labels, reduced motion, and high contrast.
|
||||
|
||||
## Phase 6 — Durability, Operations, and Scale
|
||||
|
||||
- [ ] Choose and implement the durability contract from the decision table.
|
||||
For acknowledged persistence, return success only after a writer receipt or
|
||||
use a durable outbox with retries and ordering.
|
||||
- [ ] Expose counters/metrics for queue depth, enqueue rejection, write latency,
|
||||
failed transaction, barrier latency, replay read latency, and terminal-memory
|
||||
eviction.
|
||||
- [ ] Add request/game correlation fields to logs without logging JWTs,
|
||||
passwords, or full private payloads.
|
||||
- [ ] Configure a stable production JWT signing key (prefer a secret file or
|
||||
deployment secret) so persisted sessions can survive a server restart;
|
||||
document rotation and invalidation procedures.
|
||||
- [ ] Add a bounded degraded-mode recovery procedure; current behavior requires
|
||||
operator intervention/restart and cannot reconstruct missing writes.
|
||||
- [ ] Benchmark list and history queries with realistic user/archive sizes and
|
||||
verify query plans in CI.
|
||||
- [ ] Set WAL checkpoint and database backup procedures; test online backup and
|
||||
restore with active reads/writes.
|
||||
- [ ] Define database retention separately for anonymous, temporary-user,
|
||||
permanent-user, and curated games.
|
||||
- [ ] Add authorized durable deletion/anonymization if required by the privacy
|
||||
policy, with archive records protected from accidental cascades.
|
||||
|
||||
## Required Test Matrix
|
||||
|
||||
- [ ] Upgrade a production-shaped legacy database to every new schema version
|
||||
and reopen it with foreign keys enabled on multiple pooled connections.
|
||||
- [ ] Read history immediately after create, move, terminal move, slot claim,
|
||||
player reconfiguration, and undo—without sleeps.
|
||||
- [ ] Run concurrent legal moves from one position; exactly one may commit and
|
||||
the loser must receive `GAME_CONFLICT`.
|
||||
- [ ] Submit duplicate computer triggers; only one engine task may run.
|
||||
- [ ] Fill the write queue/fault SQLite and assert degraded health, visible
|
||||
logging, and documented client behavior.
|
||||
- [ ] Shut down with queued writes and prove all accepted writes drain.
|
||||
- [ ] Restart after a finished game and replay the exact FEN sequence/result.
|
||||
- [ ] Evict a terminal game from memory and replay it from SQLite.
|
||||
- [ ] Change a claimed human slot to computer and verify “My games” association
|
||||
remains.
|
||||
- [ ] Verify registration duplicate/session failures roll back account creation
|
||||
and capacity eviction.
|
||||
- [ ] Exercise public/private history rules for anonymous, owner, opponent, and
|
||||
unrelated authenticated clients.
|
||||
- [ ] Validate imported PGNs with promotions, castling, en passant, custom FEN,
|
||||
comments, and every supported result.
|
||||
- [ ] Run Go unit/race tests, HTTP integration scripts, JavaScript syntax/tests,
|
||||
and browser end-to-end replay navigation in CI.
|
||||
|
||||
## Replay Definition of Done
|
||||
|
||||
- A finished player game survives restart, appears once in its owner's list,
|
||||
and replays deterministically from the stored initial FEN to the stored final
|
||||
FEN in both clients.
|
||||
- A curated game is imported idempotently with source attribution, appears in a
|
||||
stable public collection, and uses the same replay path as a player game.
|
||||
- Undo, player reconfiguration, terminal eviction, and concurrent requests
|
||||
cannot produce a stale result, missing claim, duplicate ply, or mixed history
|
||||
snapshot.
|
||||
- Privacy, retention, durable deletion, and degraded-write behavior are
|
||||
documented and enforced consistently by API, storage, web, and CLI layers.
|
||||
- Query plans and benchmarks show no redundant indexes or unbounded list scans
|
||||
at the agreed deployment size.
|
||||
- [ ] Metrics: write-queue depth, retries, degraded transitions, flush and
|
||||
replay latency, KDF wait time.
|
||||
- [ ] Degraded-mode recovery without a restart (replay missing writes from
|
||||
memory or fail gameplay closed); today recovery requires a restart.
|
||||
- [ ] Live-game restoration after restart (unfinished games are replay-only).
|
||||
- [ ] Retention and user-initiated deletion or anonymization of games.
|
||||
- [ ] Thread request contexts from HTTP handlers into storage calls; today each
|
||||
call carries its own deadline.
|
||||
- [ ] Draw and resign flows (D5) for live play, independent of replay.
|
||||
Reference in new issue
Block a user