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

+34 -14
View File
@@ -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
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
+286
View File
@@ -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
View File
@@ -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
View File
@@ -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.