v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
52ac0fe31a
commit
f1ac4cbbe7
71 files changed
+7677
-537
No files matched your search
+157
-13
@@ -157,6 +157,11 @@ Authorization: Bearer <token>
|
||||
|
||||
Note: When authenticated, human player IDs match the user's ID. Anonymous players receive unique UUIDs.
|
||||
|
||||
A custom `fen` must describe a position that can arise in a game: one king
|
||||
per side, no pawn on the first or last rank, and the side not to move not in
|
||||
check; otherwise the response is 400 `INVALID_FEN` with the reason. Castling
|
||||
rights whose king or rook has left its original square are dropped.
|
||||
|
||||
### Get Game
|
||||
`GET /games/{gameId}`
|
||||
|
||||
@@ -190,7 +195,8 @@ the game ID, matching the existing public live-game read model. Persistent
|
||||
storage must be enabled.
|
||||
|
||||
The response contains the initial FEN and an ordered FEN after every move, so a
|
||||
client can replay the game without running a chess engine.
|
||||
client can replay the game without running a chess engine. Each move also
|
||||
carries its SAN, derived on read from the stored position before it.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
@@ -198,6 +204,8 @@ client can replay the game without running a chess engine.
|
||||
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||
"result": "white_wins",
|
||||
"pgnResult": "1-0",
|
||||
"termination": "checkmate",
|
||||
"startTimeUtc": "2026-09-07T12:00:00Z",
|
||||
"endTimeUtc": "2026-09-07T12:15:00Z",
|
||||
"players": {
|
||||
@@ -208,6 +216,7 @@ client can replay the game without running a chess engine.
|
||||
{
|
||||
"moveNumber": 1,
|
||||
"moveUci": "e2e4",
|
||||
"san": "e4",
|
||||
"fenAfterMove": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
|
||||
"playerColor": "w",
|
||||
"moveTimeUtc": "2026-09-07T12:00:05Z"
|
||||
@@ -217,28 +226,87 @@ 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`. A claimed player's `name`
|
||||
is their username when the claim was recorded; it survives later renames and
|
||||
`white_wins`, `black_wins`, `draw`, and `stalemate`. `pgnResult` is the PGN
|
||||
token for the same outcome (`1-0`, `0-1`, `1/2-1/2`, or `*` while ongoing),
|
||||
and `termination` names how it was reached (see
|
||||
[Game End](#game-end-draws-and-resignation); omitted while ongoing).
|
||||
`moveNumber` counts plies from 1. `san` is omitted only for a stored move the
|
||||
rules core cannot notate, which the server logs. 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.
|
||||
|
||||
Undo stays available after a result (a finished game can be rewound and
|
||||
played on), so a stored game is never final. Responses carry a strong `ETag`
|
||||
and `Cache-Control: private, no-cache`: send `If-None-Match` to get 304 while
|
||||
nothing changed.
|
||||
|
||||
Returns 400 for a non-canonical game ID, 404 when the game has no durable
|
||||
record, and 503 when persistence is disabled or degraded.
|
||||
|
||||
### Export PGN
|
||||
`GET /games/{gameId}/pgn?ply=N`
|
||||
|
||||
Returns the stored game in PGN export format as
|
||||
`application/x-chess-pgn; charset=utf-8` with
|
||||
`Content-Disposition: attachment; filename="chess-<yyyymmdd>-<id8>.pgn"`.
|
||||
Same visibility, `ETag`, and error responses as the history. `ply` (optional,
|
||||
0 up to the number of stored plies) exports only the first N plies; the
|
||||
result is then `*`, the `Termination` tag is left out, and the filename ends
|
||||
`-ply<N>.pgn`. A `fetch` of the URL returns the text, so clients can copy it
|
||||
as well as download it.
|
||||
|
||||
```
|
||||
[Event "Casual game"]
|
||||
[Site "?"]
|
||||
[Date "2026.09.27"]
|
||||
[Round "-"]
|
||||
[White "alice"]
|
||||
[Black "Stockfish level 3"]
|
||||
[Result "0-1"]
|
||||
[GameId "68007fd1-8970-4d84-950d-2f6ec6375c0b"]
|
||||
[UTCDate "2026.09.27"]
|
||||
[UTCTime "08:07:00"]
|
||||
[WhiteType "human"]
|
||||
[BlackType "program"]
|
||||
[PlyCount "4"]
|
||||
[Termination "normal"]
|
||||
|
||||
1. f3 e5 2. g4 Qh4# 0-1
|
||||
```
|
||||
|
||||
The movetext of a finished game ends with a comment naming the outcome
|
||||
before the result, e.g. `{ Black wins by resignation. } 0-1` or
|
||||
`{ Draw by threefold repetition. } 1/2-1/2`.
|
||||
|
||||
Players are the name snapshot, `Anonymous`, or `Stockfish level N`. A game
|
||||
from a custom position adds `SetUp "1"` and `FEN` tags, and its movetext
|
||||
starts at that position's move number (`55. g8=Q` or `40... Kd8`).
|
||||
Movetext wraps at 80 columns. `Termination` is `normal` for a finished game
|
||||
and `unterminated` otherwise.
|
||||
|
||||
### List My Stored Games
|
||||
`GET /users/me/games?limit=50&offset=0`
|
||||
`GET /users/me/games?limit=50&cursor=<nextCursor>&status=finished&color=white`
|
||||
|
||||
Returns games associated with the authenticated user at creation time or by a
|
||||
later first-move slot claim. Requires `Authorization: Bearer <token>` and
|
||||
persistent storage.
|
||||
later first-move slot claim, newest first. Requires
|
||||
`Authorization: Bearer <token>` and persistent storage.
|
||||
|
||||
- `limit`: 1-100; defaults to 50
|
||||
- `offset`: 0-1,000,000; defaults to 0
|
||||
- `cursor`: the previous page's `nextCursor`; continues after that game, so
|
||||
games created meanwhile do not shift the page. Opaque; do not construct it.
|
||||
- `status`: `ongoing` or `finished`
|
||||
- `color`: `white` or `black`, the side the user claimed
|
||||
- `offset`: 0-1,000,000; position-based paging for older clients. It cannot
|
||||
be combined with `cursor`, and pages shift when games are added.
|
||||
|
||||
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.
|
||||
or the initial FEN when there is none), result and its `pgnResult` token,
|
||||
timestamps, players, and move count. `nextCursor` is present only when another
|
||||
page exists; `nextOffset` also, on requests without a cursor. 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.
|
||||
|
||||
Returns 400 `INVALID_REQUEST` for a malformed cursor or filter.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
@@ -249,6 +317,7 @@ the association survives later player reconfiguration.
|
||||
"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",
|
||||
"pgnResult": "1-0",
|
||||
"startTimeUtc": "2026-09-07T12:00:00Z",
|
||||
"endTimeUtc": "2026-09-07T12:15:00Z",
|
||||
"moveCount": 7,
|
||||
@@ -259,7 +328,8 @@ the association survives later player reconfiguration.
|
||||
}
|
||||
],
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
"offset": 0,
|
||||
"nextCursor": "MTc5MDQ5NjQyMDU4MTg2NS42ODAwN2ZkMS04OTcwLTRkODQtOTUwZC0yZjZlYzYzNzVjMGI"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -273,15 +343,89 @@ Submits human move or triggers computer move.
|
||||
{"move": "e2e4"}
|
||||
```
|
||||
|
||||
Moves are UCI: castling is the king's two-square move (`e1g1`), and a
|
||||
promotion appends the piece (`e7e8q`, `r`, `b`, or `n`). A pawn move to the
|
||||
last rank without the piece returns 400 `INVALID_MOVE` with
|
||||
`promotion piece required`.
|
||||
|
||||
**Computer move trigger:**
|
||||
```json
|
||||
{"move": "cccc"}
|
||||
```
|
||||
|
||||
A move that ends the game (mate, stalemate, or an automatic draw) returns the
|
||||
terminal state and `termination` in the same response.
|
||||
|
||||
### Game End: Draws and Resignation
|
||||
|
||||
A live game (`GET /games/{gameId}` and every game response) carries:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `state` | `ongoing`, `pending` (computer thinking), `stuck` (engine failed), `white wins`, `black wins`, `stalemate`, or `draw` |
|
||||
| `termination` | How a finished game ended: `checkmate`, `resignation`, `stalemate`, `insufficient_material`, `threefold_repetition`, `fifty_move_rule`, or `agreement`; omitted while unfinished |
|
||||
| `drawOffer` | `w` or `b`: that side's draw offer awaits an answer; omitted when none |
|
||||
| `drawOutcome` | Only on responses to `POST .../draw`: `offered`, `accepted`, or `declined` |
|
||||
|
||||
**Automatic draws.** After every move the game is drawn, without a claim, as
|
||||
on most online servers, when:
|
||||
|
||||
- neither side can mate: bare kings, one minor piece, or only bishops all on
|
||||
squares of one color (two knights do not count: mate is possible);
|
||||
- the position occurs for the third time with the same side to move,
|
||||
castling rights, and en-passant possibility (threefold repetition);
|
||||
- 50 moves by each side pass without a capture or pawn move (the halfmove
|
||||
clock reaches 100).
|
||||
|
||||
Mate and stalemate take precedence. A custom starting position that is
|
||||
already dead, or whose halfmove clock is at 100, is created drawn.
|
||||
|
||||
#### Resign
|
||||
`POST /games/{gameId}/resign`
|
||||
|
||||
```json
|
||||
{"color": "w"}
|
||||
```
|
||||
|
||||
Ends the game in the other side's favor, with `termination: "resignation"`.
|
||||
`color` (`w`, `b`, `white`, `black`) may be omitted when the server can infer
|
||||
the side: the one side the authenticated caller claimed, else the only human
|
||||
side. Allowed while the game is unfinished, including while the computer is
|
||||
thinking (its move is discarded) or the engine is stuck.
|
||||
|
||||
#### Offer, accept, or decline a draw
|
||||
`POST /games/{gameId}/draw`
|
||||
|
||||
```json
|
||||
{"action": "offer", "color": "b"}
|
||||
```
|
||||
|
||||
`action` is `offer`, `accept`, or `decline`; `color` works as for resign.
|
||||
Allowed only while `state` is `ongoing`.
|
||||
|
||||
- **To a human:** the offer stands (`drawOffer`) until the opponent accepts,
|
||||
declines, or makes a move instead, which declines it; the offerer's own
|
||||
move keeps it. Offering while the opponent's offer stands accepts it.
|
||||
Repeating a standing offer is a no-op. Waiting long-poll clients are woken
|
||||
on offers and answers.
|
||||
- **To the computer:** answered at once in `drawOutcome`. The computer
|
||||
declines before both sides have made ten moves; after that it accepts when
|
||||
a short full-strength search rates its own side level or worse.
|
||||
- **One offer per move:** after an offer is declined, the same side must make
|
||||
a move before offering again (409 `GAME_CONFLICT`).
|
||||
|
||||
Resign and draw share the move endpoint's authorization: a side claimed by a
|
||||
user acts only for that user (403 `UNAUTHORIZED`), and an authenticated
|
||||
resignation or acceptance claims an unclaimed side as a first move would.
|
||||
Other errors are 400: `GAME_OVER` for a finished game, `INVALID_REQUEST` for
|
||||
an ambiguous or computer side, a missing offer, or a game that is not
|
||||
ongoing. Both return the game.
|
||||
|
||||
### Undo Moves
|
||||
`POST /games/{gameId}/undo`
|
||||
|
||||
Reverts moves from history.
|
||||
Reverts moves from history. Undo stays available after a result, including a
|
||||
resignation or an agreed draw: it rewinds the result and any draw offer.
|
||||
|
||||
### Configure Players
|
||||
`PUT /games/{gameId}/players`
|
||||
|
||||
+59
-14
@@ -57,7 +57,14 @@ 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
|
||||
- **Game** (`internal/game`): Game state with snapshot history and player associations
|
||||
- **Board** (`internal/board`): FEN parsing and ASCII generation
|
||||
- **Rules core** (`internal/server/chess`): dependency-free position model,
|
||||
legal move generation, SAN, PGN, ASCII board, dead-material and repetition
|
||||
detection. It validates custom starting FENs, applies the automatic draw
|
||||
rules, and shadows the engine on every live move; Stockfish stays the
|
||||
authority for move legality, mate, and stalemate
|
||||
- **Replay** (`internal/server/replay`): works on stored game records: SAN
|
||||
for every ply, PGN assembly with the termination comment, and verification
|
||||
against the rules (used by `db verify`, `db pgn`, and the integrity sweep)
|
||||
- **Core** (`internal/core`): Shared types, API models, error constants
|
||||
- **CLI** (`cmd/chess-server/cli`): Database and user management commands
|
||||
- **Client** (`cmd/chess-client-cli`, `internal/client`): Interactive debugging client with command registry, session management, and colored terminal output
|
||||
@@ -84,19 +91,41 @@ schema is current, which lets a DML-only runtime role start the server.
|
||||
1. HTTP handler receives `POST /games/{id}/moves` with move
|
||||
2. Optional JWT validation for user verification
|
||||
3. Creates MakeMoveCommand, calls `processor.Execute()`
|
||||
4. Processor validates move via locked validation engine
|
||||
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 database transaction
|
||||
9. Returns GameResponse
|
||||
4. The rules core checks the move first only to name a missing promotion
|
||||
piece; the processor then validates it via the locked validation engine
|
||||
5. If legal, gets new FEN from engine, and logs a warning if the rules core
|
||||
disagrees on legality or the resulting position
|
||||
6. If neither mate nor stalemate, the processor applies the automatic draw
|
||||
rules to the new position and the game's position history: dead material,
|
||||
threefold repetition, fifty-move rule
|
||||
7. Calls `service.ApplyMoveWithState()` with the FEN, turn, state, and
|
||||
termination that were decided
|
||||
8. Service rejects a stale concurrent commit or atomically updates the move,
|
||||
optional slot claim, terminal result, and termination; a move by the
|
||||
recipient of a draw offer declines it
|
||||
9. The same logical mutation is queued as one database transaction
|
||||
10. Returns GameResponse
|
||||
|
||||
### Resignation and Draw Agreement
|
||||
1. `POST /games/{id}/resign` or `/draw` with an optional side
|
||||
2. The processor resolves the acting side (requested, else the caller's one
|
||||
claimed side, else the only human side) and checks slot ownership
|
||||
3. A draw offer to a human is recorded on the game and wakes long-polls; an
|
||||
offer to a computer is answered at once from a short full-strength search
|
||||
on the validation engine (after twenty plies; accept when level or worse
|
||||
for the computer); one offer per own move
|
||||
4. `service.EndGame()` re-checks under its lock that the game is unfinished,
|
||||
unchanged, and the accepted offer still stands, then sets result and
|
||||
termination, claims an empty acting side for an authenticated caller, and
|
||||
queues one transaction
|
||||
|
||||
### Computer Move
|
||||
1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}`
|
||||
2. Processor sets game state to `pending`
|
||||
3. Submits task to EngineQueue, returns immediately
|
||||
4. Worker goroutine calculates move with dedicated Stockfish instance
|
||||
5. Callback updates game state via service
|
||||
5. Callback re-validates the move on the validation engine, applies the
|
||||
automatic draw rules, and commits via the service
|
||||
6. Client polls for completion
|
||||
7. Returns GameResponse
|
||||
|
||||
@@ -115,8 +144,10 @@ schema is current, which lets a DML-only runtime role start the server.
|
||||
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 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
|
||||
5. The replay package derives SAN for each ply from the stored FEN before it
|
||||
6. The API returns the initial FEN plus every UCI move, its SAN, and the
|
||||
resulting FEN, with a strong ETag; `/pgn` renders the same read as PGN
|
||||
7. This path works after terminal-memory eviction or a server restart
|
||||
|
||||
## Persistence Flow
|
||||
|
||||
@@ -206,7 +237,7 @@ 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 (version 1)
|
||||
### Storage Schema (version 2)
|
||||
```sql
|
||||
users (
|
||||
user_id uuid PRIMARY KEY,
|
||||
@@ -236,6 +267,9 @@ games (
|
||||
black_... (same columns),
|
||||
start_time_utc timestamptz NOT NULL,
|
||||
result text, -- white_wins, black_wins, draw, stalemate
|
||||
termination text, -- how: checkmate, resignation, stalemate,
|
||||
-- insufficient_material, threefold_repetition,
|
||||
-- fifty_move_rule, agreement
|
||||
end_time_utc timestamptz, -- set exactly when result is set
|
||||
)
|
||||
|
||||
@@ -250,7 +284,9 @@ moves (
|
||||
)
|
||||
```
|
||||
|
||||
`schema_version` records the applied version. Indexes cover session expiry,
|
||||
`schema_version` records the applied version; version 2 added
|
||||
`termination`, constrained to be set exactly with `result` and to fit it
|
||||
(wins by checkmate or resignation, draws by a draw rule or agreement). 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
|
||||
@@ -264,9 +300,18 @@ outlives deleted accounts, and the name snapshot keeps it readable.
|
||||
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.
|
||||
- A write whose game row was deleted by hand fails only that game: the
|
||||
writer drops it and the service unloads the game, instead of degrading
|
||||
storage for everyone.
|
||||
- With `-db-cleanup report|delete`, the hourly cleanup also runs an integrity
|
||||
sweep: loaded games without a row, games with missing plies, stored games
|
||||
failing verification (200 per run, resuming in ID order), games whose
|
||||
registered players were all deleted, and accounts with unusable password
|
||||
hashes. `report` only logs; `delete` unloads and removes them.
|
||||
|
||||
See [`deploy/postgresql`](../deploy/postgresql) for provisioning and
|
||||
[deployment.md](./deployment.md) for the jail service.
|
||||
See [`deploy/postgresql`](../deploy/postgresql) for provisioning,
|
||||
[deployment.md](./deployment.md) for the jail service, and
|
||||
[deployment-linux.md](./deployment-linux.md) for systemd.
|
||||
|
||||
## Security Architecture
|
||||
|
||||
|
||||
+46
-6
@@ -14,11 +14,11 @@ The chess client is an interactive command-line debugging tool for the chess ser
|
||||
|
||||
## Replay Foundation
|
||||
|
||||
The Go API client exposes `GetGameHistory(gameID)` and
|
||||
`GetMyGames(limit, offset)`, including durable result, timestamps, player
|
||||
claims, move count, ordered UCI moves, and FEN after every move. Interactive
|
||||
`games`/`replay` commands and terminal playback controls are intentionally
|
||||
deferred to the dedicated replay iteration; see [Replay Implementation Tasks](./todo.md).
|
||||
The Go API client exposes `GetGameHistory(gameID)`, `GetGamePGN(gameID, ply)`,
|
||||
and `GetMyGames(limit, cursor)`, including durable result, timestamps, player
|
||||
claims, move count, ordered UCI and SAN moves, and FEN after every move. The
|
||||
`games` and `pgn` commands use them; step-by-step `replay` controls are still
|
||||
planned, see [Replay Implementation Tasks](./todo.md).
|
||||
|
||||
## Building
|
||||
```bash
|
||||
@@ -104,10 +104,12 @@ chess > join a1b2c3d4-e5f6-7890-1234-567890abcdef
|
||||
```
|
||||
|
||||
#### `move` / `m`
|
||||
Make chess move in UCI notation.
|
||||
Make chess move in UCI notation. Castling is the king's move (`e1g1`); a
|
||||
promotion appends the piece letter (`q`, `r`, `b`, or `n`).
|
||||
```
|
||||
chess > move e2e4
|
||||
chess > move e7e5
|
||||
chess > move g7g8q
|
||||
```
|
||||
|
||||
#### `computer` / `c`
|
||||
@@ -148,6 +150,44 @@ Long-poll for game updates (waits up to 30 seconds).
|
||||
chess > poll
|
||||
```
|
||||
|
||||
#### `resign`
|
||||
Resign the current game. The side may be omitted when you play one human side
|
||||
(or claimed one); name it in a hot-seat game.
|
||||
```
|
||||
chess > resign
|
||||
chess > resign b
|
||||
```
|
||||
|
||||
#### `draw`
|
||||
Offer (the default), accept, or decline a draw. A computer answers an offer
|
||||
at once; a human opponent sees the offer in their next response, and it
|
||||
lapses if they move instead of answering.
|
||||
```
|
||||
chess > draw # offer
|
||||
chess > draw accept b
|
||||
chess > draw decline w
|
||||
```
|
||||
|
||||
Game outcomes print with how they were reached, e.g. `DRAW by threefold
|
||||
repetition.` or `Black wins by resignation.`; a pending offer is shown as
|
||||
`White offers a draw`.
|
||||
|
||||
#### `games` / `g`
|
||||
List your stored games, newest first, 20 per page (requires login). The
|
||||
result column is the PGN token (`1-0`, `0-1`, `1/2-1/2`, `*`).
|
||||
```
|
||||
chess > games # Newest games
|
||||
chess > games more # Next page
|
||||
```
|
||||
|
||||
#### `pgn`
|
||||
Print a stored game as PGN (SAN movetext, standard tags).
|
||||
```
|
||||
chess > pgn # Current game, every ply
|
||||
chess > pgn 10 # Current game, first 10 plies
|
||||
chess > pgn <gameId> [ply] # Another game
|
||||
```
|
||||
|
||||
### Debug Commands
|
||||
|
||||
#### `health` / `.`
|
||||
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
# Database Operations
|
||||
|
||||
`psql` recipes for inspecting and maintaining the chess database as the
|
||||
PostgreSQL administrator. Installation is in [deployment.md](deployment.md)
|
||||
(FreeBSD jail) and [deployment-linux.md](deployment-linux.md) (Linux with
|
||||
systemd).
|
||||
|
||||
## Typical Layout
|
||||
|
||||
The server expects one database with its tables in one schema of the same
|
||||
name, owned by a role that exists only for it:
|
||||
|
||||
| Object | Name | Notes |
|
||||
|---|---|---|
|
||||
| Database | `chess` | Owned by `postgres`; only the `chess` role may connect |
|
||||
| Schema | `chess` | All tables; `public` is locked down and stays empty |
|
||||
| Login role | `chess` | No password, no superuser, create-database, or create-role rights; 20 connections |
|
||||
| Owner role (split mode only) | `chess_owner` | Cannot log in; owns the schema while `chess` only reads and writes rows |
|
||||
|
||||
The service runs as an OS account named `chess` and connects over the local
|
||||
Unix socket with **peer** authentication, which maps the OS account to the
|
||||
role of the same name, so no password exists anywhere. The `chess` role has
|
||||
its `search_path` set to `chess` in this database, so the service never names
|
||||
the schema. [`deploy/postgresql/setup.sql`](../deploy/postgresql/setup.sql)
|
||||
creates all of this; the deployment scripts run it once.
|
||||
|
||||
Administration uses the `postgres` superuser account, which keeps the
|
||||
distribution's defaults. Its `search_path` (`"$user", public`) does not
|
||||
include `chess`, so **every recipe names the schema explicitly** (`chess.games`).
|
||||
This keeps other databases and tools on the same server unaffected, and a
|
||||
statement typed into the wrong database fails instead of acting on whatever
|
||||
it finds.
|
||||
|
||||
## Connecting
|
||||
|
||||
```sh
|
||||
# Linux
|
||||
sudo -u postgres psql -X -d chess
|
||||
# FreeBSD jail
|
||||
su -m postgres -c 'psql -X -d chess'
|
||||
```
|
||||
|
||||
Working with the `chess` schema as `postgres`:
|
||||
|
||||
| Goal | How |
|
||||
|---|---|
|
||||
| List the tables | `\dt chess.*` (a plain `\dt` shows `public`, which is empty) |
|
||||
| Describe a table | `\d chess.games` |
|
||||
| List schemas and owners | `\dn+` |
|
||||
| Query | `SELECT ... FROM chess.games` |
|
||||
| Unqualified names for one session | `SET search_path = chess;` (lasts until you disconnect) |
|
||||
| Unqualified names for one invocation | `sudo -u postgres env PGOPTIONS='-c search_path=chess' psql -X -d chess` |
|
||||
|
||||
Neither of the last two changes anything stored: the next session starts with
|
||||
the defaults again. Avoid `ALTER ROLE postgres ... SET search_path`, which
|
||||
would persist.
|
||||
|
||||
The tables:
|
||||
|
||||
| Table | Contents |
|
||||
|---|---|
|
||||
| `chess.schema_version` | One row: the applied migration |
|
||||
| `chess.users` | Accounts: lowercase username and email, Argon2id password hash |
|
||||
| `chess.sessions` | One active session per user; deleting it signs the user out |
|
||||
| `chess.games` | Players, claims (`*_claimed_by`), name snapshots, `result`, `termination`, times |
|
||||
| `chess.moves` | Ply `move_number` 1..n, UCI move, FEN after the move |
|
||||
|
||||
`result` is `white_wins`, `black_wins`, `stalemate`, or `draw`, and
|
||||
`termination` says how: `checkmate`, `resignation`, `stalemate`,
|
||||
`insufficient_material`, `threefold_repetition`, `fifty_move_rule`, or
|
||||
`agreement`. Both are empty while a game is unfinished.
|
||||
|
||||
`\set g '<game-id>'` stores a game ID for the `:'g'` references below.
|
||||
|
||||
## Overview
|
||||
|
||||
```sql
|
||||
SELECT version, updated_at FROM chess.schema_version;
|
||||
|
||||
SELECT (SELECT count(*) FROM chess.users) AS users,
|
||||
(SELECT count(*) FROM chess.games) AS games,
|
||||
(SELECT count(*) FROM chess.games WHERE result IS NULL) AS unfinished,
|
||||
(SELECT count(*) FROM chess.moves) AS plies,
|
||||
(SELECT count(*) FROM chess.sessions WHERE expires_at > now()) AS signed_in;
|
||||
|
||||
SELECT coalesce(termination, 'unfinished') AS termination, count(*)
|
||||
FROM chess.games GROUP BY 1 ORDER BY 2 DESC;
|
||||
```
|
||||
|
||||
## Users
|
||||
|
||||
```sql
|
||||
SELECT user_id, username, email, created_at, last_login_at
|
||||
FROM chess.users ORDER BY created_at;
|
||||
```
|
||||
|
||||
Passwords are not stored. `password_hash` is an Argon2id string in PHC
|
||||
format, `$argon2id$v=19$m=<KiB>,t=<passes>,p=<lanes>$<salt>$<hash>`, from
|
||||
which the password cannot be recovered. Show the parameters without the
|
||||
secret parts:
|
||||
|
||||
```sql
|
||||
SELECT username, split_part(password_hash, '$', 2) AS algorithm,
|
||||
split_part(password_hash, '$', 4) AS parameters
|
||||
FROM chess.users;
|
||||
```
|
||||
|
||||
Create, reset, and rename accounts with the server's CLI, not SQL: it
|
||||
normalizes names, enforces uniqueness rules, and hashes passwords. Without
|
||||
`-password` it prompts, which keeps the password out of the shell history and
|
||||
the process list.
|
||||
|
||||
```sh
|
||||
# Linux: chess-db runs `chess-server db ...` as the chess account
|
||||
sudo chess-db user add -username <name> [-email <addr>]
|
||||
sudo chess-db user set-password -username <name>
|
||||
sudo chess-db user list
|
||||
# FreeBSD jail
|
||||
su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
|
||||
```
|
||||
|
||||
Other `user` subcommands: `delete`, `set-email`, `set-username`, and
|
||||
`set-hash` (an existing PHC string). CLI-created accounts are identical to
|
||||
site registrations, and the CLI ignores the registration cap.
|
||||
|
||||
Sign a user out everywhere (takes effect on their next request):
|
||||
|
||||
```sql
|
||||
DELETE FROM chess.sessions
|
||||
WHERE user_id = (SELECT user_id FROM chess.users WHERE username = 'alice');
|
||||
```
|
||||
|
||||
Deleting a user removes their session; their games keep the claim and the
|
||||
name snapshot. Games left with no remaining registered player are removed by
|
||||
the optional integrity sweep (below) once idle for a day.
|
||||
|
||||
## Games
|
||||
|
||||
Recent games with players, outcome, and length:
|
||||
|
||||
```sql
|
||||
SELECT g.game_id, g.start_time_utc,
|
||||
coalesce(g.white_name, CASE g.white_type WHEN 2 THEN 'Stockfish L' || g.white_level ELSE 'anonymous' END) AS white,
|
||||
coalesce(g.black_name, CASE g.black_type WHEN 2 THEN 'Stockfish L' || g.black_level ELSE 'anonymous' END) AS black,
|
||||
coalesce(g.result || ' by ' || g.termination, 'unfinished') AS outcome,
|
||||
(SELECT count(*) FROM chess.moves m WHERE m.game_id = g.game_id) AS plies
|
||||
FROM chess.games g ORDER BY g.start_time_utc DESC LIMIT 20;
|
||||
```
|
||||
|
||||
A user's games: add
|
||||
`WHERE (SELECT user_id FROM chess.users WHERE username = 'alice') IN (g.white_claimed_by, g.black_claimed_by)`.
|
||||
Find a game from the 8-digit prefix that `db query` prints:
|
||||
`SELECT game_id FROM chess.games WHERE game_id::text LIKE '68007fd1%';`.
|
||||
|
||||
One game, header and every ply with its FEN:
|
||||
|
||||
```sql
|
||||
\set g '68007fd1-8970-4d84-950d-2f6ec6375c0b'
|
||||
SELECT * FROM chess.games WHERE game_id = :'g' \gx
|
||||
SELECT move_number AS ply, player_color, move_uci, fen_after_move, move_time_utc
|
||||
FROM chess.moves WHERE game_id = :'g' ORDER BY move_number;
|
||||
```
|
||||
|
||||
Final position:
|
||||
|
||||
```sql
|
||||
SELECT coalesce(
|
||||
(SELECT fen_after_move FROM chess.moves WHERE game_id = :'g' ORDER BY move_number DESC LIMIT 1),
|
||||
(SELECT initial_fen FROM chess.games WHERE game_id = :'g')) AS final_fen;
|
||||
```
|
||||
|
||||
The move line in UCI with move numbers (numbering follows the initial FEN, so
|
||||
a game that starts with Black to move begins `N...`):
|
||||
|
||||
```sql
|
||||
WITH s AS (SELECT split_part(initial_fen, ' ', 2) AS side,
|
||||
split_part(initial_fen, ' ', 6)::int AS fullmove
|
||||
FROM chess.games WHERE game_id = :'g')
|
||||
SELECT string_agg(
|
||||
CASE WHEN m.player_color = 'w'
|
||||
THEN (s.fullmove + (m.move_number - CASE s.side WHEN 'w' THEN 1 ELSE 0 END) / 2) || '. '
|
||||
WHEN m.move_number = 1 THEN s.fullmove || '... '
|
||||
ELSE '' END || m.move_uci,
|
||||
' ' ORDER BY m.move_number) AS uci_line
|
||||
FROM chess.moves m, s WHERE m.game_id = :'g';
|
||||
```
|
||||
|
||||
SQL has no SAN; the database stores UCI and notation is derived on read. For
|
||||
real PGN use the CLI (a full ID or 8-digit prefix; `-ply N` stops after N
|
||||
plies) or the API:
|
||||
|
||||
```sh
|
||||
sudo chess-db pgn -gameId 68007fd1 # Linux
|
||||
su -m chess -c '/home/chess/bin/chess-server db pgn -gameId 68007fd1 -dsn "postgres:///chess?host=/tmp"' # FreeBSD
|
||||
curl -s https://<site>/chess/api/games/<game-id>/pgn
|
||||
```
|
||||
|
||||
`db verify` checks every stored game against the rules (move numbering, each
|
||||
move legal from the stored position before it, the stored position after it
|
||||
reproduced, the result and its termination consistent with the final
|
||||
position) and exits non-zero on any problem:
|
||||
|
||||
```sh
|
||||
sudo chess-db verify
|
||||
```
|
||||
|
||||
### Deleting games
|
||||
|
||||
Delete finished or unfinished games at any time:
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
DELETE FROM chess.games WHERE game_id = :'g'; -- its moves are removed by cascade
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
All games of one user (both colors):
|
||||
|
||||
```sql
|
||||
DELETE FROM chess.games
|
||||
WHERE (SELECT user_id FROM chess.users WHERE username = 'alice') IN (white_claimed_by, black_claimed_by);
|
||||
```
|
||||
|
||||
A game the server is still playing is unloaded when its next write finds the
|
||||
row gone (that last move is not stored), or earlier by the integrity sweep;
|
||||
the server stays healthy. Deleting individual `chess.moves` rows instead leaves a broken line
|
||||
that replay cannot use: delete the whole game, or let the integrity sweep do
|
||||
it.
|
||||
|
||||
Games without a registered player are already deleted by the server 24 hours
|
||||
after their last activity (`-anonymous-game-ttl`).
|
||||
|
||||
### Integrity sweep
|
||||
|
||||
With the server flag `-db-cleanup report` (log only) or `-db-cleanup delete`,
|
||||
an hourly sweep looks for data the server cannot use:
|
||||
|
||||
- games whose rows were deleted while the server held them (unloaded);
|
||||
- games with missing plies, i.e. deleted move rows;
|
||||
- games that fail `db verify`, 200 per run in ID order;
|
||||
- games whose registered players were all deleted, once idle for the
|
||||
anonymous-game retention;
|
||||
- accounts whose password hash is not a usable Argon2id string (nobody can
|
||||
sign in to them).
|
||||
|
||||
Each finding is logged at warning level with its reason; `delete` also
|
||||
removes them. Start with `report`, read the log, then switch. Set it through
|
||||
`CHESSD_FLAGS` in `/etc/chessd/chessd.env` (Linux) or `chessd_flags` in
|
||||
`rc.conf` (FreeBSD), and restart the service.
|
||||
|
||||
## Starting Fresh
|
||||
|
||||
Every option below loses data: take a dump first if any of it matters.
|
||||
|
||||
```sh
|
||||
sudo -u postgres pg_dump -Fc -d chess -f /var/lib/postgresql/chess-$(date +%F).dump # Linux (Debian/Ubuntu path)
|
||||
su -m postgres -c 'pg_dump -Fc -d chess -f /var/db/postgres/chess-$(date +%F).dump' # FreeBSD
|
||||
```
|
||||
|
||||
The service commands are `systemctl stop|start chessd` on Linux and
|
||||
`service chessd stop|start` on FreeBSD.
|
||||
|
||||
**Clear the data, keep the schema** (accounts, sessions, and games), with the
|
||||
service stopped:
|
||||
|
||||
```sql
|
||||
TRUNCATE chess.moves, chess.games, chess.sessions, chess.users;
|
||||
```
|
||||
|
||||
**Recreate the tables** (drops and re-runs the migrations), with the service
|
||||
stopped:
|
||||
|
||||
```sh
|
||||
sudo chess-db delete -confirm && sudo chess-db init # Linux
|
||||
su -m chess -c '/home/chess/bin/chess-server db delete -confirm -dsn "postgres:///chess?host=/tmp"' # FreeBSD
|
||||
su -m chess -c '/home/chess/bin/chess-server db init -dsn "postgres:///chess?host=/tmp"'
|
||||
```
|
||||
|
||||
`db init` prints `Database schema ready (version 2)`. In split mode the
|
||||
`chess` role owns no tables and cannot drop or create them; run both commands
|
||||
as `postgres` acting as the owner role instead, e.g. on Linux:
|
||||
|
||||
```sh
|
||||
sudo -u postgres /usr/local/bin/chess-server db delete -confirm \
|
||||
-dsn "dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'"
|
||||
```
|
||||
|
||||
**Rebuild the database and role** (as on a new host), with the service
|
||||
stopped:
|
||||
|
||||
```sql
|
||||
DROP DATABASE IF EXISTS chess WITH (FORCE);
|
||||
DROP ROLE IF EXISTS chess_owner; -- exists only in split mode
|
||||
DROP ROLE IF EXISTS chess;
|
||||
```
|
||||
|
||||
Then rerun the deployment script (`deploy/linux/setup.sh` or
|
||||
`deploy/freebsd/setup-jail.sh`) with the same inputs as before. With neither
|
||||
role nor database present it runs `setup.sql`, creates the tables, starts the
|
||||
service, and checks `/health`. The JWT key is kept; accounts are gone, so
|
||||
create them again.
|
||||
|
||||
Verify any of them:
|
||||
|
||||
```sql
|
||||
\dt chess.* -- games, moves, schema_version, sessions, users
|
||||
SELECT version FROM chess.schema_version; -- 2
|
||||
\drds -- chess: search_path and timeouts in database chess
|
||||
```
|
||||
@@ -0,0 +1,228 @@
|
||||
# Deployment: Linux with systemd and PostgreSQL
|
||||
|
||||
A native install on Debian, Ubuntu, Arch, or another systemd distribution
|
||||
that already runs PostgreSQL. The service is `chessd`, running as a system
|
||||
account `chess` without a login shell; it reaches PostgreSQL over the local
|
||||
Unix socket with peer authentication, so no database password exists. A
|
||||
reverse proxy (nginx) on the same host, if any, forwards `/chess/api/` to it.
|
||||
The FreeBSD jail layout is in [deployment.md](deployment.md); database
|
||||
maintenance is in [database.md](database.md).
|
||||
|
||||
```
|
||||
browser ──TLS──▶ nginx ──HTTP 127.0.0.1:8080──▶ chessd (chess-server, user chess)
|
||||
│ Unix socket, peer auth
|
||||
▼
|
||||
PostgreSQL 17+ (database chess)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
PostgreSQL 17 or later (18 recommended), running, with its default local
|
||||
socket; Stockfish; systemd; Go 1.27 to build.
|
||||
|
||||
**Debian / Ubuntu.** Ubuntu 24.04 ships PostgreSQL 16, so 18 comes from the
|
||||
PostgreSQL project's repository (PGDG). With 18 already installed, check the
|
||||
cluster with `pg_lsclusters` and skip this.
|
||||
|
||||
```sh
|
||||
sudo apt install postgresql-common stockfish
|
||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
|
||||
sudo apt install postgresql-18 # creates and starts cluster 18/main
|
||||
```
|
||||
|
||||
The default `pg_hba.conf` already maps local OS accounts to roles of the same
|
||||
name (`local all all peer`). Stockfish installs as `/usr/games/stockfish`;
|
||||
the service adds `/usr/games` to its `PATH`.
|
||||
|
||||
**Arch.** The package does not create a cluster; initialize one the first
|
||||
time only:
|
||||
|
||||
```sh
|
||||
sudo pacman -S postgresql stockfish
|
||||
sudo -u postgres initdb -D /var/lib/postgres/data --encoding=UTF8 \
|
||||
--locale-provider=builtin --builtin-locale=C.UTF-8 \
|
||||
--auth-local=peer --auth-host=scram-sha-256
|
||||
sudo systemctl enable --now postgresql
|
||||
```
|
||||
|
||||
A cluster made by a plain `initdb` trusts every local connection. The chess
|
||||
account connects either way, but `peer` is what you want: without it, any
|
||||
local account can connect as any role.
|
||||
|
||||
**Existing clusters.** Other databases, roles, and applications on the
|
||||
server are left alone: the setup creates one role and one database named
|
||||
`chess`, changes no server settings, and edits `pg_hba.conf` only when asked
|
||||
(`PG_HBA=yes`, below). It does not change `listen_addresses`; the service
|
||||
itself only uses the socket.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
make server # bin/chess-server, static
|
||||
sudo CHESS_BINARY=bin/chess-server TRUSTED_PROXIES=127.0.0.1 deploy/linux/setup.sh
|
||||
sudo chess-db user add -username <name> # prompts for the password
|
||||
```
|
||||
|
||||
The script is idempotent: rerun it with a new binary to upgrade. It stops
|
||||
`chessd`, installs everything, migrates the schema, starts the service, and
|
||||
fails unless `/health` reports `"storage":"ok"`.
|
||||
|
||||
| Input | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `CHESS_BINARY` | required | `chess-server` binary to install |
|
||||
| `TRUSTED_PROXIES` | kept from `chessd.env` | Proxy address(es) whose `X-Real-IP` is trusted; `127.0.0.1` for nginx on this host |
|
||||
| `CHESSD_HOST`, `CHESSD_PORT` | `127.0.0.1`, `8080` | Listen address, first install only |
|
||||
| `SPLIT_PRIVILEGES` | `no` | `yes`: the service role only reads and writes rows ([database.md](database.md#typical-layout)) |
|
||||
| `ENABLE_BACKUP` | `no` | `yes`: nightly `pg_dump` through a systemd timer |
|
||||
| `PG_HBA` | `no` | `yes`: add `local chess chess peer` to `pg_hba.conf` if the chess account cannot connect |
|
||||
|
||||
What it installs:
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| account `chess` | System account, no login shell, home `/var/lib/chessd` |
|
||||
| `/usr/local/bin/chess-server` | The server, `root:root 0755`; the previous copy kept as `.prev` |
|
||||
| `/usr/local/sbin/chess-db` | Runs `chess-server db ...` as `chess` against the database: `chess-db user list`, `chess-db verify` |
|
||||
| `/etc/chessd/chessd.env` | Settings, `root:chess 0640`; written on the first install only |
|
||||
| `/var/lib/chessd/jwt.key` | JWT signing key, `chess 0600`, generated once; sessions survive restarts |
|
||||
| `/etc/systemd/system/chessd.service` | The service unit, replaced on every run |
|
||||
| `chess-backup.service`, `.timer`, `/usr/local/sbin/chess-backup` | With `ENABLE_BACKUP=yes`: dumps to `backups/` in the postgres home, 14 days kept |
|
||||
|
||||
PostgreSQL steps, in order: check the server version (17+) and socket as
|
||||
`postgres`; create role, database, and schema with
|
||||
[`setup.sql`](../deploy/postgresql/setup.sql) when neither exists (a
|
||||
half-present pair stops the script); check that the `chess` account can
|
||||
connect.
|
||||
|
||||
### When the chess account cannot connect
|
||||
|
||||
The first matching line of `pg_hba.conf` decides. A cluster whose local
|
||||
lines require a password (`scram-sha-256`, `md5`) refuses the passwordless
|
||||
`chess` role, and the script stops with the file's path and this line to add
|
||||
above the other `local` lines:
|
||||
|
||||
```
|
||||
local chess chess peer
|
||||
```
|
||||
|
||||
Then reload (`sudo systemctl reload postgresql`) and rerun, or rerun with
|
||||
`PG_HBA=yes` to have the line inserted and PostgreSQL reloaded (the previous
|
||||
file is kept next to it with a timestamp).
|
||||
|
||||
## Settings
|
||||
|
||||
`/etc/chessd/chessd.env` is read by the unit; restart after a change
|
||||
(`sudo systemctl restart chessd`).
|
||||
|
||||
| Setting | First-install value | Purpose |
|
||||
|---|---|---|
|
||||
| `CHESSD_HOST`, `CHESSD_PORT` | `127.0.0.1`, `8080` | Listen address; keep loopback when nginx runs on the host |
|
||||
| `CHESSD_DSN` | `"host=<socket dir> dbname=chess"` | Database over the socket as the OS account |
|
||||
| `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key |
|
||||
| `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses |
|
||||
| `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report` |
|
||||
|
||||
Server flags worth knowing: `-max-users` (registration cap, default 100),
|
||||
`-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`),
|
||||
`-db-cleanup off|report|delete` (the integrity sweep, see
|
||||
[database.md](database.md#integrity-sweep)), `-log-level`, `-log-http`.
|
||||
|
||||
Changes to the unit belong in a drop-in (`sudo systemctl edit chessd`), which
|
||||
the script never overwrites.
|
||||
|
||||
## Operation
|
||||
|
||||
```sh
|
||||
systemctl status chessd
|
||||
journalctl -u chessd -f # server log
|
||||
sudo chess-db user add -username <name> # accounts; see database.md
|
||||
sudo chess-db verify # check stored games; run after upgrades
|
||||
curl -s http://127.0.0.1:8080/health # "storage":"ok"
|
||||
systemctl list-timers chess-backup.timer # with ENABLE_BACKUP=yes
|
||||
```
|
||||
|
||||
**Upgrade:** build, then rerun `setup.sh` with the new `CHESS_BINARY`.
|
||||
Schema migrations run before the service starts.
|
||||
|
||||
**Rollback:** until you delete it, the previous binary is kept:
|
||||
|
||||
```sh
|
||||
sudo systemctl stop chessd
|
||||
sudo cp /usr/local/bin/chess-server.prev /usr/local/bin/chess-server
|
||||
sudo systemctl start chessd
|
||||
```
|
||||
|
||||
A newer schema is not downgraded: a binary older than the database refuses to
|
||||
start (see the log), so keep a dump from before the upgrade.
|
||||
|
||||
**Uninstall:**
|
||||
|
||||
```sh
|
||||
sudo systemctl disable --now chessd chess-backup.timer
|
||||
sudo rm -f /etc/systemd/system/chessd.service /etc/systemd/system/chess-backup.{service,timer} \
|
||||
/usr/local/bin/chess-server{,.prev} /usr/local/sbin/chess-{db,backup}
|
||||
sudo systemctl daemon-reload
|
||||
sudo rm -rf /etc/chessd /var/lib/chessd
|
||||
sudo -u postgres psql -X -c 'DROP DATABASE chess WITH (FORCE)' -c 'DROP ROLE IF EXISTS chess_owner' -c 'DROP ROLE chess'
|
||||
sudo userdel chess
|
||||
```
|
||||
|
||||
## nginx on the Same Host
|
||||
|
||||
Use [`deploy/nginx-chess.conf`](../deploy/nginx-chess.conf) with
|
||||
`<server-address>:<port>` = `127.0.0.1:8080`, and install with
|
||||
`TRUSTED_PROXIES=127.0.0.1`, so the server keys rate limits on the client
|
||||
address nginx sends in `X-Real-IP`. The browser clients and their
|
||||
publishing steps are the same as on FreeBSD
|
||||
([deployment.md](deployment.md#host-nginx-and-web-clients)).
|
||||
|
||||
## Service Sandbox
|
||||
|
||||
The unit runs the server with systemd's sandboxing: the whole file system is
|
||||
read-only except `/var/lib/chessd`, home directories and `/tmp` are hidden,
|
||||
no capabilities or privilege escalation, only Unix and IP sockets, and the
|
||||
`@system-service` system-call set. Stockfish runs inside the same sandbox.
|
||||
`systemd-analyze security chessd` summarizes the result.
|
||||
|
||||
## Running as Your Own Account (Development)
|
||||
|
||||
Without a `chess` account, the server runs as you, with a database owned by
|
||||
your PostgreSQL role; tables then live in that database's `public` schema,
|
||||
which is fine for development:
|
||||
|
||||
```sh
|
||||
sudo -u postgres createuser --createdb "$USER" # once, if you have no role yet
|
||||
createdb chess_dev
|
||||
bin/chess-server db init -dsn "dbname=chess_dev"
|
||||
bin/chess-server -dev -dsn "dbname=chess_dev" -serve # API :8080, web client :9090
|
||||
bin/chess-server db user add -username me -dsn "dbname=chess_dev"
|
||||
```
|
||||
|
||||
`-dev` relaxes rate limits and uses a fixed JWT key; never use it in
|
||||
production. The test suites need a separate, disposable database whose
|
||||
tables they drop ([test/README.md](../test/README.md)):
|
||||
|
||||
```sh
|
||||
createdb chess_test
|
||||
export CHESS_TEST_DSN="dbname=chess_test"
|
||||
go test ./...
|
||||
test/run-test-server.sh bin/chess-server # then test/test-*.sh in another terminal
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
```sh
|
||||
systemctl is-active chessd # active
|
||||
curl -s http://127.0.0.1:8080/health # "storage":"ok"
|
||||
ps -o user= -C chess-server # chess
|
||||
sudo -u postgres psql -X -d chess -c '\dt chess.*' # five tables
|
||||
sudo chess-db verify # 0 problem(s)
|
||||
sudo -u chess psql -X -d chess -Atc 'SHOW search_path' # chess
|
||||
```
|
||||
|
||||
The scripts were exercised on Ubuntu 24.04 with PostgreSQL 18.6 and
|
||||
`systemctl` replaced by a stand-in that starts the unit's `ExecStart` as its
|
||||
`User` with its `EnvironmentFile`: a first install that stops at
|
||||
`pg_hba.conf`, the same with `PG_HBA=yes`, an idempotent rerun, an upgrade,
|
||||
a trusted-proxy change, backups, and a fresh split-privilege install.
|
||||
`systemd-analyze verify` accepts the units.
|
||||
+45
-9
@@ -16,6 +16,9 @@ browser ──TLS──▶ host nginx ──HTTP──▶ jail: chessd (chess-se
|
||||
|
||||
`host#` runs on the Bastille host as root; `jail#` runs inside the jail as root
|
||||
(`bastille console <jail>`). Addresses in this document are placeholders.
|
||||
For a native Linux install with systemd, see
|
||||
[deployment-linux.md](deployment-linux.md); database recipes are in
|
||||
[database.md](database.md).
|
||||
|
||||
## Scripted Installation and Upgrade
|
||||
|
||||
@@ -56,7 +59,7 @@ Their header comments list every input.
|
||||
unless `/health` reports `"storage":"ok"`.
|
||||
|
||||
5. **Host:** point nginx at the jail
|
||||
([`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf))
|
||||
([`deploy/nginx-chess.conf`](../deploy/nginx-chess.conf))
|
||||
and publish the web client (below).
|
||||
|
||||
`setup-jail.sh` inputs:
|
||||
@@ -105,6 +108,21 @@ rerun, and a fresh split-privilege install.
|
||||
|
||||
## Accounts and Data Retention
|
||||
|
||||
A new database has no accounts. Create the first one (for example your own)
|
||||
with the CLI; without `-password` it prompts, so the password stays out of
|
||||
the shell history and the process list:
|
||||
|
||||
```sh
|
||||
jail# su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
|
||||
```
|
||||
|
||||
The row lands in `chess.users` with an Argon2id hash in `password_hash`; the
|
||||
password itself is stored nowhere and cannot be read back, only replaced
|
||||
(`db user set-password`). The tables are in schema `chess`, not `public`:
|
||||
as `postgres`, list them with `\dt chess.*` and name them as `chess.games`.
|
||||
[database.md](database.md) explains the layout and has the psql recipes
|
||||
(inspecting and deleting games, sessions, starting fresh).
|
||||
|
||||
- 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
|
||||
@@ -116,6 +134,10 @@ rerun, and a fresh split-privilege install.
|
||||
activity**, 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.
|
||||
With `-db-cleanup delete`, games left without any registered player are
|
||||
deleted like anonymous ones.
|
||||
- A game row deleted by hand while the server still plays it no longer
|
||||
degrades storage: the server unloads that game and carries on.
|
||||
|
||||
## Service (`rc.d/chessd`)
|
||||
|
||||
@@ -132,7 +154,7 @@ rerun, and a fresh split-privilege install.
|
||||
| `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user |
|
||||
| `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing |
|
||||
| `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted |
|
||||
| `chessd_flags` | empty | Extra flags, e.g. `-max-users 500` |
|
||||
| `chessd_flags` | empty | Extra flags, e.g. `-max-users 500 -db-cleanup report` |
|
||||
|
||||
The script runs `daemon(8)` as root, which writes the supervisor pidfile
|
||||
(`/var/run/chessd.pid`), drops to `chess`, restarts the server 5 seconds after
|
||||
@@ -151,6 +173,7 @@ Server flags:
|
||||
| `-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`) |
|
||||
| `-db-cleanup` | | Hourly integrity sweep: `off` (default), `report` (log findings), or `delete` ([database.md](database.md#integrity-sweep)) |
|
||||
|
||||
Administrative CLI commands run as `chess` so peer authentication selects the
|
||||
right role; root may use `su -m` although the account has no shell:
|
||||
@@ -159,11 +182,19 @@ right role; root may use `su -m` although the account has no shell:
|
||||
jail# su -m chess -c '/home/chess/bin/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
|
||||
```
|
||||
|
||||
`db` subcommands: `init`, `delete -confirm` (drops the tables), `query`
|
||||
(games, with 8-digit ID prefixes), `pgn -gameId <id|prefix> [-ply N]`,
|
||||
`verify [-gameId <id|prefix>]` (replays stored games against the rules and
|
||||
exits non-zero on a mismatch; run it after an upgrade), and `user add|delete|
|
||||
set-password|set-hash|set-email|set-username|list`.
|
||||
|
||||
## Host nginx and Web Clients
|
||||
|
||||
The API has no version segment: routes are `/api/...` and `/health`. With the
|
||||
layout in [`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf), browsers
|
||||
call `<origin>/chess/api/...` and nginx maps `/chess/api/` to `/api/`.
|
||||
layout in [`nginx-chess.conf`](../deploy/nginx-chess.conf), browsers
|
||||
call `<origin>/chess/api/...` and nginx maps `/chess/api/` to `/api/`. The
|
||||
bare `/chess` and `/chess/` redirect (302) to the page that embeds the
|
||||
client, as a short link.
|
||||
|
||||
- `X-Real-IP` must carry the real client address (`$remote_addr`, after
|
||||
`real_ip` processing when PROXY protocol is in front of nginx). The server
|
||||
@@ -181,10 +212,14 @@ server's API paths:
|
||||
embedded `/config` endpoint, it uses the `/chess` API prefix.
|
||||
- **WASM terminal client (if published):** `make wasm` (xterm.js is committed
|
||||
under `lib/`), then copy `web/chess-client-wasm/`. It derives its API base
|
||||
as `<origin>/chess`.
|
||||
as `<origin>/chess`. Its API paths are compiled into `chess-client.wasm`,
|
||||
so rebuild it whenever `internal/client/api` changes; publish it together
|
||||
with the `wasm_exec.js` that `make wasm` copies from the same Go toolchain.
|
||||
|
||||
Publish the clients in the same change as the server: a client built for
|
||||
`/api/v1` gets 404 from this release.
|
||||
`web/chess-client-web` is a symlink to the embedded web client; copy with
|
||||
`cp -RL` or `rsync -L` so the files, not the link, reach the site. Publish the
|
||||
clients in the same change as the server, and expect browsers to hold cached
|
||||
copies until a hard refresh.
|
||||
|
||||
## Reference
|
||||
|
||||
@@ -253,8 +288,9 @@ Restore the previous web client files on the host as well.
|
||||
|
||||
### Backups and restores
|
||||
|
||||
- `chess-backup` writes `pg_dump --format=custom` files nightly and removes
|
||||
those older than 14 days. Copy them off the jail.
|
||||
- `chess-backup` ([`deploy/postgresql/chess-backup.sh`](../deploy/postgresql/chess-backup.sh),
|
||||
shared with Linux) writes `pg_dump --format=custom` files 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).
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.26+
|
||||
- Go 1.27+
|
||||
- Stockfish in PATH
|
||||
- PostgreSQL 18 (server for persistence; `psql` for the shell test suites)
|
||||
- Git
|
||||
|
||||
+54
-42
@@ -28,6 +28,9 @@ into phases that each ship and test on their own.
|
||||
- [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`.
|
||||
- [x] Rules core (`internal/server/chess`, R1) and replay API (R2), below.
|
||||
- [x] Pawn promotion in the web client (piece picker) and a specific API error
|
||||
for a promotion without its piece.
|
||||
|
||||
## Decisions
|
||||
|
||||
@@ -37,53 +40,52 @@ into phases that each ship and test on their own.
|
||||
| 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. |
|
||||
| D5 | Draw rules | **Done.** Draws apply automatically after every move, as on most online servers: dead material, threefold repetition, and the fifty-move rule (FIDE's claimable draws, and so also its 75-move and fivefold rules). Draw offers (a computer answers from its evaluation) and resignation exist; the stored `termination` says how a game ended. |
|
||||
| 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. |
|
||||
| D7 | List pagination | **Done.** Opaque keyset `cursor` on `(start_time_utc, game_id)`; `offset` kept for existing clients and exclusive with `cursor`. |
|
||||
|
||||
## Phase R1 — Notation Core (server, no API change)
|
||||
## Phase R1 — Notation Core (server) — done
|
||||
|
||||
A small, dependency-free chess core in Go (`internal/server/chess`), used by
|
||||
replay only; Stockfish remains the move validator for live play.
|
||||
A dependency-free rules core in Go (`internal/server/chess`). Stockfish
|
||||
remains the move validator for live play; the core notates stored games and
|
||||
shadows the engine on every live move, logging any disagreement.
|
||||
|
||||
- [ ] 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`.
|
||||
- [x] Position with strict FEN parse (one king per side, no pawns on the back
|
||||
ranks, side not to move not in check; castling rights without their rook
|
||||
dropped) and canonical serialization (en-passant square only when a capture
|
||||
is legal, so Stockfish's pseudo-legal convention normalizes away).
|
||||
- [x] Legal move generation with castling paths, en passant, promotion, pins.
|
||||
- [x] SAN encode (file/rank/square disambiguation, `+`/`#`, `O-O`, `=Q`) and
|
||||
lenient decode (`0-0`, `e8Q`, annotations, over-disambiguation).
|
||||
- [x] PGN writer: Seven Tag Roster, supplemental tags, `SetUp`/`FEN` for custom
|
||||
starts with correct move numbering, 80-column movetext, escaped tag values.
|
||||
- [x] Draw-rule helpers (D5) and `chess-server db verify`, which replays every
|
||||
stored game against the stored FENs and results.
|
||||
- [x] Custom starting FENs validated by the core before the engine sees them.
|
||||
|
||||
Test gate:
|
||||
|
||||
- [ ] 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.
|
||||
- [x] Perft for the CPW start, Kiwipete, and positions 3–6 (depth 4; position
|
||||
3 to depth 5).
|
||||
- [x] Engine cross-check: random games from all six positions, comparing the
|
||||
legal move set with Stockfish `go perft 1` and the FEN with Stockfish `d` at
|
||||
every ply (about 6,000 positions per run; skipped without Stockfish).
|
||||
- [x] Fuzz targets: FEN parse round trip with SAN/UCI round trip of every
|
||||
legal move, SAN decoding, PGN tag escaping.
|
||||
- [ ] `db verify` over a copy of the production database once it holds games.
|
||||
|
||||
## Phase R2 — Replay API
|
||||
## Phase R2 — Replay API — done
|
||||
|
||||
- [ ] Implement D7 (keyset cursor) once decided.
|
||||
- [ ] Additive history fields: `san` per move, `outcome`/`termination`, and
|
||||
`pgnResult`; keep existing fields unchanged.
|
||||
- [ ] `GET /api/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.
|
||||
- [x] D7 keyset cursor, plus `status` and `color` filters on the list.
|
||||
- [x] Additive history fields: `san` per move, `pgnResult`, `termination`.
|
||||
- [x] `GET /api/games/{id}/pgn?ply=N` as an attachment; `db pgn` in the CLI.
|
||||
- [x] Strong `ETag` with `Cache-Control: private, no-cache` on history and PGN
|
||||
(revalidate every time: D6 lets finished games change); `If-None-Match`
|
||||
returns 304.
|
||||
- [x] Tests: rules-core unit, fuzz, and engine cross-check; service and
|
||||
handler tests against PostgreSQL; `test/test-replay.sh` over HTTP (SAN,
|
||||
PGN, ETag, promotion, FEN validation, cursor paging and filters).
|
||||
- [x] Terminal client: `games` (cursor paging) and `pgn` commands.
|
||||
|
||||
## Phase R3 — Web Replay UI
|
||||
|
||||
@@ -101,7 +103,11 @@ Plain JavaScript in the embedded client, no framework.
|
||||
- [ ] 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>`).
|
||||
from R2 as a `Blob`, saved through a temporary `<a download>`). The live
|
||||
view's "Copy PGN" button copies UCI moves today; point it at the PGN
|
||||
endpoint too.
|
||||
- [ ] Show SAN in the live move list (from the history, or a `san` field on
|
||||
the live game response).
|
||||
- [ ] While replaying: stop live long-polling, disable move, undo, new-game,
|
||||
and player-configuration controls; **Back to live game** restores them.
|
||||
|
||||
@@ -129,8 +135,9 @@ Test gate:
|
||||
|
||||
## Phase R4 — CLI and WASM Replay (optional)
|
||||
|
||||
- [ ] `games` (list with pagination), `replay <gameId|index>`, `next`, `prev`,
|
||||
`first`, `last`, `goto <ply>`, `auto <ms>`, `fen`, `pgn save <path>`.
|
||||
- [x] `games` (list with cursor paging) and `pgn [gameId] [ply]`.
|
||||
- [ ] `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.
|
||||
|
||||
@@ -156,4 +163,9 @@ Test gate:
|
||||
- [ ] 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.
|
||||
- [x] Draw and resign flows (D5) for live play.
|
||||
- [x] A game row deleted by hand no longer degrades storage; optional
|
||||
integrity sweep (`-db-cleanup`) for broken or orphaned data.
|
||||
- [ ] Once `db verify` and the live shadow check stay clean in production,
|
||||
consider validating human moves with the core instead of the engine: no
|
||||
engine round trip or lock per move, and Stockfish only for computer play.
|
||||
Reference in new issue
Block a user