v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
5d2be4abb2
commit
d0326b059e
52 files changed
+3951
-2368
No files matched your search
@@ -0,0 +1,43 @@
|
||||
name: test
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: chess_test
|
||||
POSTGRES_PASSWORD: chess_test
|
||||
POSTGRES_DB: chess_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U chess_test -d chess_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 5s
|
||||
--health-retries 30
|
||||
env:
|
||||
CHESS_TEST_DSN: postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.26.x'
|
||||
- name: gofmt
|
||||
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
|
||||
- run: go vet ./...
|
||||
- run: go test -race -count=1 ./...
|
||||
- name: Cross-compile static FreeBSD server and WASM client
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
run: |
|
||||
GOOS=freebsd GOARCH=amd64 go build -trimpath -o /dev/null ./cmd/chess-server
|
||||
GOOS=js GOARCH=wasm go build -trimpath -o /dev/null ./cmd/chess-client-cli
|
||||
@@ -10,8 +10,13 @@ GO := go
|
||||
GOROOT := $(shell go env GOROOT)
|
||||
GOFLAGS := -trimpath
|
||||
LDFLAGS := -s -w
|
||||
CGO_SERVER := CGO_ENABLED=1
|
||||
CGO_CLIENT := CGO_ENABLED=0
|
||||
# Both binaries are pure Go (pgx has no C dependency), so builds are static and
|
||||
# cross-compile without a C toolchain.
|
||||
CGO := CGO_ENABLED=0
|
||||
|
||||
# PostgreSQL connection for run/db targets. Keyword/value or URL form; the
|
||||
# default uses the local Unix socket and peer authentication.
|
||||
CHESS_DSN ?= dbname=chess
|
||||
|
||||
# Build info
|
||||
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
@@ -40,22 +45,17 @@ all: build
|
||||
.PHONY: build
|
||||
build: server client
|
||||
|
||||
# Build server only
|
||||
# Build server only. Phony: the Go build cache decides what is stale, so a
|
||||
# changed source file is never masked by an existing binary.
|
||||
.PHONY: server
|
||||
server: $(SERVER_BINARY)
|
||||
|
||||
# Build server — CGO required for go-sqlite3
|
||||
$(SERVER_BINARY): $(BINARY_DIR)
|
||||
$(CGO_SERVER) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE)
|
||||
server: | $(BINARY_DIR)
|
||||
$(CGO) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE)
|
||||
@echo "Built server: $(SERVER_BINARY)"
|
||||
|
||||
# Build client only
|
||||
.PHONY: client
|
||||
client: $(CLIENT_BINARY)
|
||||
|
||||
# Build client — pure Go, no CGO
|
||||
$(CLIENT_BINARY): $(BINARY_DIR)
|
||||
$(CGO_CLIENT) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
|
||||
client: | $(BINARY_DIR)
|
||||
$(CGO) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
|
||||
@echo "Built client: $(CLIENT_BINARY)"
|
||||
|
||||
# Create bin directory
|
||||
@@ -66,7 +66,7 @@ $(BINARY_DIR):
|
||||
.PHONY: wasm
|
||||
wasm: $(WASM_DIR)
|
||||
@echo "Building WASM client..."
|
||||
$(CGO_CLIENT) GOOS=js GOARCH=wasm $(GO) build $(GOFLAGS) \
|
||||
$(CGO) GOOS=js GOARCH=wasm $(GO) build $(GOFLAGS) \
|
||||
-ldflags "$(LDFLAGS)" \
|
||||
-o $(WASM_BINARY) $(CLIENT_SOURCE)
|
||||
@cp "$(WASM_EXEC_SRC)" $(WASM_DIR)/
|
||||
@@ -112,21 +112,28 @@ $(WASM_DIR):
|
||||
# Run server with default settings
|
||||
.PHONY: run-server
|
||||
run-server: server
|
||||
$(SERVER_BINARY) -api-port 8080 -dev -storage-path db/chess.db
|
||||
CHESS_DSN='$(CHESS_DSN)' $(SERVER_BINARY) -api-port 8080 -dev
|
||||
|
||||
# Run server with web UI
|
||||
.PHONY: run-server-web
|
||||
run-server-web: server
|
||||
$(SERVER_BINARY) -api-port 8080 -dev -storage-path db/chess.db -serve -web-port 9090
|
||||
CHESS_DSN='$(CHESS_DSN)' $(SERVER_BINARY) -api-port 8080 -dev -serve -web-port 9090
|
||||
|
||||
# Run client
|
||||
.PHONY: run-client
|
||||
run-client: client
|
||||
$(CLIENT_BINARY)
|
||||
|
||||
# Run tests (start server and run test scripts)
|
||||
# Go unit and PostgreSQL integration tests. Database tests are skipped unless
|
||||
# CHESS_TEST_DSN names a disposable database (see test/README.md).
|
||||
.PHONY: test
|
||||
test: server
|
||||
test:
|
||||
$(GO) vet ./...
|
||||
$(GO) test -race -count=1 ./...
|
||||
|
||||
# Start a test server for the shell suites (requires CHESS_TEST_DSN)
|
||||
.PHONY: test-server
|
||||
test-server: server
|
||||
test/run-test-server.sh
|
||||
|
||||
# Run individual test suites
|
||||
@@ -145,25 +152,25 @@ test-longpoll:
|
||||
# Database operations
|
||||
.PHONY: db-init
|
||||
db-init: server
|
||||
$(SERVER_BINARY) db init -path db/chess.db
|
||||
CHESS_DSN='$(CHESS_DSN)' $(SERVER_BINARY) db init
|
||||
|
||||
.PHONY: db-clean
|
||||
db-clean:
|
||||
# ☣ DESTRUCTIVE: Removes database
|
||||
rm -f db/chess.db db/chess.db-*
|
||||
db-clean: server
|
||||
# ☣ DESTRUCTIVE: drops every chess table in the DSN's search_path
|
||||
CHESS_DSN='$(CHESS_DSN)' $(SERVER_BINARY) db delete -confirm
|
||||
|
||||
# Cross-compile server for FreeBSD from Linux (requires zig or freebsd cross toolchain)
|
||||
# Usage: make server-freebsd-cross CC="zig cc -target x86_64-freebsd"
|
||||
.PHONY: server-freebsd-cross
|
||||
server-freebsd-cross: $(BINARY_DIR)
|
||||
$(CGO_SERVER) GOOS=freebsd GOARCH=amd64 CC="$(CC)" \ $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE)
|
||||
@echo "Built FreeBSD server (cross): $(SERVER_BINARY)"
|
||||
# Cross-compile a static server for FreeBSD/amd64 (no C toolchain required)
|
||||
.PHONY: server-freebsd
|
||||
server-freebsd: | $(BINARY_DIR)
|
||||
$(CGO) GOOS=freebsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" \
|
||||
-o $(SERVER_BINARY)-freebsd-amd64 $(SERVER_SOURCE)
|
||||
@echo "Built FreeBSD server: $(SERVER_BINARY)-freebsd-amd64"
|
||||
|
||||
# Development build (with race detector) — native only, CGO required for server
|
||||
# Development build with the race detector (the race runtime requires cgo)
|
||||
.PHONY: dev
|
||||
dev:
|
||||
$(CGO_SERVER) $(GO) build -race -o $(SERVER_BINARY) $(SERVER_SOURCE)
|
||||
$(CGO_CLIENT) $(GO) build -race -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
|
||||
dev: | $(BINARY_DIR)
|
||||
CGO_ENABLED=1 $(GO) build -race -o $(SERVER_BINARY) $(SERVER_SOURCE)
|
||||
CGO_ENABLED=1 $(GO) build -race -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
|
||||
@echo "Built with race detector enabled"
|
||||
|
||||
# Clean build artifacts
|
||||
@@ -206,6 +213,7 @@ help:
|
||||
@echo " make client Build client only"
|
||||
@echo " make wasm Build WASM client"
|
||||
@echo " make wasm-full Build WASM with dependencies"
|
||||
@echo " make server-freebsd Cross-compile static FreeBSD/amd64 server"
|
||||
@echo " make dev Build with race detector"
|
||||
@echo ""
|
||||
@echo "Run targets:"
|
||||
@@ -215,14 +223,15 @@ help:
|
||||
@echo " make wasm-serve Serve WASM client (port 8081)"
|
||||
@echo ""
|
||||
@echo "Test targets:"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make test Run vet and Go tests (set CHESS_TEST_DSN for DB tests)"
|
||||
@echo " make test-server Start the shell-suite test server (needs CHESS_TEST_DSN)"
|
||||
@echo " make test-api Run API tests"
|
||||
@echo " make test-db Run database tests"
|
||||
@echo " make test-longpoll Run long-poll tests"
|
||||
@echo ""
|
||||
@echo "Database targets:"
|
||||
@echo " make db-init Initialize database"
|
||||
@echo " make db-clean Remove database (destructive)"
|
||||
@echo "Database targets (CHESS_DSN=$(CHESS_DSN)):"
|
||||
@echo " make db-init Create or migrate the schema"
|
||||
@echo " make db-clean Drop all chess tables (destructive)"
|
||||
@echo ""
|
||||
@echo "WASM targets:"
|
||||
@echo " make wasm-deps Download xterm.js dependencies"
|
||||
|
||||
@@ -23,11 +23,13 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
|
||||
- Custom FEN position support
|
||||
- Asynchronous engine move calculation
|
||||
- Configurable engine strength and thinking time
|
||||
- SQLite persistence with async writes for games
|
||||
- Durable game results, player claims, ordered move history, and replay API
|
||||
- PostgreSQL 18 persistence with ordered async writes for games
|
||||
- Durable game results, player claims and names, ordered move history, and replay API
|
||||
- Games without a registered player are purged 24 hours after their last activity
|
||||
- Authenticated stored-game listing with bounded pagination
|
||||
- Configurable structured debug logs for persistence, cleanup, and engine work
|
||||
- User management with secure Argon2 password storage
|
||||
- User management with secure Argon2id password storage and scoped JWTs
|
||||
- Static, CGO-free binaries (cross-compiles for FreeBSD)
|
||||
- PID file management for singleton enforcement
|
||||
- Database CLI for storage and user administration
|
||||
|
||||
@@ -35,7 +37,7 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
|
||||
|
||||
- Go 1.26+
|
||||
- Stockfish chess engine (`stockfish` in PATH)
|
||||
- SQLite3 (for persistence features)
|
||||
- PostgreSQL 18 (for persistence and accounts; see [Deployment](./doc/deployment.md))
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
@@ -43,9 +45,13 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
|
||||
yay -S stockfish
|
||||
|
||||
# FreeBSD
|
||||
pkg install stockfish
|
||||
pkg install stockfish postgresql18-server
|
||||
```
|
||||
|
||||
The connection string comes from `-dsn` or `CHESS_DSN`, in libpq keyword/value
|
||||
or URL form. With a local socket and peer authentication, `dbname=chess` is
|
||||
enough. Unset fields fall back to the standard `PG*` variables and `~/.pgpass`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Make (Recommended)
|
||||
@@ -54,20 +60,22 @@ git clone https://github.com/lixenwraith/chess
|
||||
cd chess
|
||||
make build
|
||||
|
||||
export CHESS_DSN='dbname=chess' # used by make targets and the CLI
|
||||
|
||||
# Create or migrate the schema (the server also does this at startup)
|
||||
make db-init
|
||||
|
||||
# Standard mode with persistence and auth
|
||||
make run-server
|
||||
|
||||
# Or run with web UI
|
||||
make run-server-web
|
||||
|
||||
# Initialize database with user support
|
||||
make db-init
|
||||
|
||||
# View all build options
|
||||
make help
|
||||
|
||||
# Add users via CLI
|
||||
./bin/chess-server db user add -path db/chess.db -username alice -password AlicePass123
|
||||
./bin/chess-server db user add -username alice -password AlicePass123
|
||||
```
|
||||
|
||||
### Building Manually
|
||||
@@ -78,53 +86,57 @@ cd chess
|
||||
go build ./cmd/chess-server
|
||||
|
||||
# Standard mode with persistence and auth
|
||||
./chess-server -storage-path chess.db
|
||||
./chess-server -dsn 'dbname=chess' -jwt-secret-file ~/.chess-jwt.key
|
||||
|
||||
# Development mode with all features
|
||||
./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -pid-lock -api-port 9090
|
||||
./chess-server -dev -dsn 'dbname=chess' -pid /tmp/chess-server.pid -pid-lock -api-port 9090
|
||||
|
||||
# Detailed persistence and HTTP diagnostics
|
||||
./chess-server -dev -storage-path chess.db -log-level debug -log-http=true
|
||||
./chess-server -dev -dsn 'dbname=chess' -log-level debug -log-http=true
|
||||
|
||||
# Initialize database with user support
|
||||
./chess-server db init -path chess.db
|
||||
# Initialize the schema
|
||||
./chess-server db init -dsn 'dbname=chess'
|
||||
|
||||
# Add users via CLI
|
||||
./chess-server db user add -path chess.db -username alice -password AlicePass123
|
||||
./chess-server db user list -path chess.db
|
||||
# Add users via CLI (-dsn defaults to $CHESS_DSN)
|
||||
./chess-server db user add -dsn 'dbname=chess' -username alice -password AlicePass123
|
||||
./chess-server db user list -dsn 'dbname=chess'
|
||||
```
|
||||
|
||||
Server listens on `http://localhost:8080`. See [API Reference](./doc/api.md) for endpoints including authentication.
|
||||
|
||||
## User Management
|
||||
|
||||
The chess server supports user accounts with secure authentication:
|
||||
The chess server supports user accounts with secure authentication. The
|
||||
commands below read the connection string from `CHESS_DSN` (or `-dsn`).
|
||||
Accounts registered on the site and accounts created here are identical and
|
||||
do not expire. Games without a registered player are deleted 24 hours after
|
||||
their last activity.
|
||||
|
||||
### Creating Users
|
||||
```bash
|
||||
# Add user with password
|
||||
./chess-server db user add -path chess.db -username alice -email alice@example.com -password SecurePass123
|
||||
./chess-server db user add -username alice -email alice@example.com -password SecurePass123
|
||||
|
||||
# Interactive password prompt
|
||||
./chess-server db user add -path chess.db -username bob -interactive
|
||||
./chess-server db user add -username bob -interactive
|
||||
|
||||
# Import with existing hash
|
||||
./chess-server db user add -path chess.db -username charlie -hash '$argon2id$...'
|
||||
./chess-server db user add -username charlie -hash '$argon2id$...'
|
||||
```
|
||||
|
||||
### Managing Users
|
||||
```bash
|
||||
# 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 NewPass456
|
||||
./chess-server db user set-password -username alice -password NewPass456
|
||||
|
||||
# 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
|
||||
|
||||
# Delete user
|
||||
./chess-server db user delete -path chess.db -username alice
|
||||
./chess-server db user delete -username alice
|
||||
```
|
||||
|
||||
## Web UI
|
||||
@@ -140,7 +152,7 @@ The chess server includes an embedded web UI for playing games through a browser
|
||||
./chess-server -serve -web-port 3000 -web-host 0.0.0.0
|
||||
|
||||
# Full example with authentication enabled
|
||||
./chess-server -dev -serve -web-port 9090 -api-port 8080 -storage-path chess.db
|
||||
./chess-server -dev -serve -web-port 9090 -api-port 8080 -dsn 'dbname=chess'
|
||||
|
||||
# Override the API origin seen by browsers when it differs from the listen address
|
||||
./chess-server -serve -web-api-url https://api.example.com
|
||||
@@ -161,10 +173,11 @@ Access the UI at `http://localhost:9090` when server is running with `-serve` fl
|
||||
## Documentation
|
||||
|
||||
- [API Reference](./doc/api.md) - Endpoint specifications including auth
|
||||
- [Deployment](./doc/deployment.md) - FreeBSD jail, PostgreSQL 18, service account, SQLite data import
|
||||
- [Architecture](./doc/architecture.md) - System design with auth layer
|
||||
- [Development](./doc/development.md) - Build, test, and user management
|
||||
- [Client Guide](./doc/client.md) - Interactive debugging client
|
||||
- [Replay Implementation Tasks](./doc/todo.md) - Remaining CLI, web, archive, and production work
|
||||
- [Replay Plan](./doc/todo.md) - Phased game-replay implementation and open decisions
|
||||
- [Stockfish Integration](./doc/stockfish.md) - Engine communication
|
||||
|
||||
## License
|
||||
|
||||
+173
-219
@@ -18,6 +18,8 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const dsnEnv = "CHESS_DSN"
|
||||
|
||||
// Run is the entry point for the CLI mini-app
|
||||
func Run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
@@ -41,74 +43,94 @@ func Run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func runInit(args []string) error {
|
||||
fs := flag.NewFlagSet("init", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
// newFlagSet returns a flag set with the shared -dsn flag. An empty -dsn falls
|
||||
// back to $CHESS_DSN (in openStore, so usage output never prints it), which
|
||||
// keeps a password-bearing DSN out of process arguments.
|
||||
func newFlagSet(name string) (*flag.FlagSet, *string) {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
dsn := fs.String("dsn", "", "PostgreSQL connection string (default $"+dsnEnv+")")
|
||||
return fs, dsn
|
||||
}
|
||||
|
||||
// openStore connects and, unless migrating, verifies the schema is current.
|
||||
func openStore(dsn string, requireSchema bool) (*storage.Store, error) {
|
||||
if dsn == "" {
|
||||
dsn = os.Getenv(dsnEnv)
|
||||
}
|
||||
if dsn == "" {
|
||||
return nil, fmt.Errorf("database connection required: use -dsn or %s", dsnEnv)
|
||||
}
|
||||
store, err := storage.NewStore(dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requireSchema {
|
||||
if err := store.CheckSchema(); err != nil {
|
||||
store.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func runInit(args []string) error {
|
||||
fs, dsn := newFlagSet("init")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.InitDB(); err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Database initialized at: %s\n", *path)
|
||||
version, err := store.SchemaVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Database schema ready (version %d)\n", version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDelete(args []string) error {
|
||||
fs := flag.NewFlagSet("delete", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
|
||||
fs, dsn := newFlagSet("delete")
|
||||
confirm := fs.Bool("confirm", false, "Confirm dropping all chess tables and data")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
if !*confirm {
|
||||
return fmt.Errorf("refusing to drop all chess tables without -confirm")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.DeleteDB(); err != nil {
|
||||
return fmt.Errorf("failed to delete database: %w", err)
|
||||
// ☣ DESTRUCTIVE: drops every chess table in the connection's search_path
|
||||
if err := store.DropSchema(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Database deleted: %s\n", *path)
|
||||
fmt.Println("Chess tables dropped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runQuery(args []string) error {
|
||||
fs := flag.NewFlagSet("query", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("query")
|
||||
gameID := fs.String("gameId", "", "Game ID to filter (optional, * for all)")
|
||||
playerID := fs.String("playerId", "", "Player ID to filter (optional, * for all)")
|
||||
|
||||
playerID := fs.String("playerId", "", "Player or user ID to filter (optional, * for all)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
@@ -189,107 +211,86 @@ func runUser(subcommand string, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// readPassword prompts on the terminal without echo.
|
||||
func readPassword(prompt string) (string, error) {
|
||||
fmt.Print(prompt)
|
||||
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
return string(pwBytes), nil
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
if len(password) < 8 {
|
||||
return "", fmt.Errorf("password must be at least 8 characters")
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func runUserAdd(args []string) error {
|
||||
fs := flag.NewFlagSet("user add", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user add")
|
||||
username := fs.String("username", "", "Username (required)")
|
||||
email := fs.String("email", "", "Email address (optional)")
|
||||
password := fs.String("password", "", "Password (optional, will prompt if not provided)")
|
||||
hash := fs.String("hash", "", "Pre-computed password hash (optional)")
|
||||
interactive := fs.Bool("interactive", false, "Interactive password prompt")
|
||||
temp := fs.Bool("temp", false, "Create as temporary user (24h TTL, default: permanent)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *username == "" {
|
||||
return fmt.Errorf("username required")
|
||||
}
|
||||
|
||||
// Validate password/hash options
|
||||
if *password != "" && *hash != "" {
|
||||
return fmt.Errorf("cannot specify both -password and -hash")
|
||||
}
|
||||
|
||||
var passwordHash string
|
||||
|
||||
if *interactive {
|
||||
switch {
|
||||
case *interactive:
|
||||
if *password != "" || *hash != "" {
|
||||
return fmt.Errorf("cannot use -interactive with -password or -hash")
|
||||
}
|
||||
fmt.Print("Enter password: ")
|
||||
pwBytes, err := term.ReadPassword(syscall.Stdin)
|
||||
fmt.Println()
|
||||
entered, err := readPassword("Enter password: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
return err
|
||||
}
|
||||
if len(pwBytes) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters")
|
||||
if passwordHash, err = hashPassword(entered); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hash password (Argon2)
|
||||
passwordHash, err = auth.HashPassword(string(pwBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
case *hash != "":
|
||||
if err := auth.ValidatePHCHashFormat(*hash); err != nil {
|
||||
return fmt.Errorf("invalid hash format: %w", err)
|
||||
}
|
||||
} else if *hash != "" {
|
||||
passwordHash = *hash
|
||||
} else if *password != "" {
|
||||
if len(*password) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters")
|
||||
}
|
||||
// Hash password (Argon2)
|
||||
case *password != "":
|
||||
var err error
|
||||
passwordHash, err = auth.HashPassword(*password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
if passwordHash, err = hashPassword(*password); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
default:
|
||||
return fmt.Errorf("password required: use -password, -hash, or -interactive")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Generate user ID with conflict check
|
||||
var userID string
|
||||
for attempts := 0; attempts < 10; attempts++ {
|
||||
userID = uuid.New().String()
|
||||
if _, err := store.GetUserByID(userID); errors.Is(err, sql.ErrNoRows) {
|
||||
// User doesn't exist, ID is unique
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to check generated user ID: %w", err)
|
||||
}
|
||||
if attempts == 9 {
|
||||
return fmt.Errorf("failed to generate unique user ID after 10 attempts")
|
||||
}
|
||||
}
|
||||
|
||||
// Determine account type (CLI default = permanent)
|
||||
accountType := "permanent"
|
||||
var expiresAt *time.Time
|
||||
if *temp {
|
||||
accountType = "temp"
|
||||
expiry := time.Now().UTC().Add(24 * time.Hour)
|
||||
expiresAt = &expiry
|
||||
}
|
||||
|
||||
userID := uuid.NewString()
|
||||
record := storage.UserRecord{
|
||||
UserID: userID,
|
||||
Username: strings.ToLower(*username),
|
||||
Email: strings.ToLower(*email),
|
||||
PasswordHash: passwordHash,
|
||||
AccountType: accountType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err := store.CreateUser(record); err != nil {
|
||||
@@ -298,50 +299,59 @@ func runUserAdd(args []string) error {
|
||||
|
||||
fmt.Printf("User created successfully:\n")
|
||||
fmt.Printf(" ID: %s\n", userID)
|
||||
fmt.Printf(" Username: %s\n", *username)
|
||||
if *email != "" {
|
||||
fmt.Printf(" Email: %s\n", *email)
|
||||
fmt.Printf(" Username: %s\n", record.Username)
|
||||
if record.Email != "" {
|
||||
fmt.Printf(" Email: %s\n", record.Email)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupUser resolves -username or -id to a user ID.
|
||||
func lookupUser(store *storage.Store, username, userID string) (string, error) {
|
||||
switch {
|
||||
case username == "" && userID == "":
|
||||
return "", fmt.Errorf("either -username or -id required")
|
||||
case username != "" && userID != "":
|
||||
return "", fmt.Errorf("specify either -username or -id, not both")
|
||||
case userID != "":
|
||||
if _, err := store.GetUserByID(userID); err != nil {
|
||||
return "", userLookupError(userID, err)
|
||||
}
|
||||
return userID, nil
|
||||
default:
|
||||
user, err := store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
return "", userLookupError(username, err)
|
||||
}
|
||||
return user.UserID, nil
|
||||
}
|
||||
}
|
||||
|
||||
func userLookupError(identifier string, err error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("user not found: %s", identifier)
|
||||
}
|
||||
return fmt.Errorf("failed to look up user %s: %w", identifier, err)
|
||||
}
|
||||
|
||||
func runUserDelete(args []string) error {
|
||||
fs := flag.NewFlagSet("user delete", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user delete")
|
||||
username := fs.String("username", "", "Username to delete")
|
||||
userID := fs.String("id", "", "User ID to delete")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *username == "" && *userID == "" {
|
||||
return fmt.Errorf("either -username or -id required")
|
||||
}
|
||||
if *username != "" && *userID != "" {
|
||||
return fmt.Errorf("specify either -username or -id, not both")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
var targetID string
|
||||
if *userID != "" {
|
||||
targetID = *userID
|
||||
} else {
|
||||
user, err := store.GetUserByUsername(*username)
|
||||
targetID, err := lookupUser(store, *username, *userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", *username)
|
||||
return err
|
||||
}
|
||||
targetID = user.UserID
|
||||
}
|
||||
|
||||
if err := store.DeleteUser(targetID); err != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
@@ -351,65 +361,49 @@ func runUserDelete(args []string) error {
|
||||
}
|
||||
|
||||
func runUserSetPassword(args []string) error {
|
||||
fs := flag.NewFlagSet("user set-password", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user set-password")
|
||||
username := fs.String("username", "", "Username (required)")
|
||||
password := fs.String("password", "", "New password")
|
||||
interactive := fs.Bool("interactive", false, "Interactive password prompt")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *username == "" {
|
||||
return fmt.Errorf("username required")
|
||||
}
|
||||
|
||||
var newPassword string
|
||||
if *interactive {
|
||||
switch {
|
||||
case *interactive:
|
||||
if *password != "" {
|
||||
return fmt.Errorf("cannot use -interactive with -password")
|
||||
}
|
||||
fmt.Print("Enter new password: ")
|
||||
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
entered, err := readPassword("Enter new password: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
return err
|
||||
}
|
||||
newPassword = string(pwBytes)
|
||||
} else if *password != "" {
|
||||
newPassword = entered
|
||||
case *password != "":
|
||||
newPassword = *password
|
||||
} else {
|
||||
default:
|
||||
return fmt.Errorf("password required: use -password or -interactive")
|
||||
}
|
||||
|
||||
if len(newPassword) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters")
|
||||
passwordHash, err := hashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Get user
|
||||
user, err := store.GetUserByUsername(*username)
|
||||
targetID, err := lookupUser(store, *username, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", *username)
|
||||
return err
|
||||
}
|
||||
|
||||
// Hash password (Argon2)
|
||||
passwordHash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Update password
|
||||
if err := store.UpdateUserPassword(user.UserID, passwordHash); err != nil {
|
||||
if err := store.UpdateUserPassword(targetID, passwordHash); err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
@@ -418,43 +412,33 @@ func runUserSetPassword(args []string) error {
|
||||
}
|
||||
|
||||
func runUserSetHash(args []string) error {
|
||||
fs := flag.NewFlagSet("user set-hash", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user set-hash")
|
||||
username := fs.String("username", "", "Username (required)")
|
||||
hash := fs.String("hash", "", "Password hash (required)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *username == "" {
|
||||
return fmt.Errorf("username required")
|
||||
}
|
||||
if *hash == "" {
|
||||
return fmt.Errorf("password hash required")
|
||||
}
|
||||
|
||||
if err := auth.ValidatePHCHashFormat(*hash); err != nil {
|
||||
return fmt.Errorf("invalid hash format: %w", err)
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Get user
|
||||
user, err := store.GetUserByUsername(*username)
|
||||
targetID, err := lookupUser(store, *username, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", *username)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update password hash directly
|
||||
if err := store.UpdateUserPassword(user.UserID, *hash); err != nil {
|
||||
if err := store.UpdateUserPassword(targetID, *hash); err != nil {
|
||||
return fmt.Errorf("failed to update password hash: %w", err)
|
||||
}
|
||||
|
||||
@@ -463,18 +447,12 @@ func runUserSetHash(args []string) error {
|
||||
}
|
||||
|
||||
func runUserSetEmail(args []string) error {
|
||||
fs := flag.NewFlagSet("user set-email", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user set-email")
|
||||
username := fs.String("username", "", "Username (required)")
|
||||
email := fs.String("email", "", "New email address (required)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *username == "" {
|
||||
return fmt.Errorf("username required")
|
||||
}
|
||||
@@ -482,20 +460,17 @@ func runUserSetEmail(args []string) error {
|
||||
return fmt.Errorf("email required")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Get user
|
||||
user, err := store.GetUserByUsername(*username)
|
||||
targetID, err := lookupUser(store, *username, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", *username)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update email
|
||||
if err := store.UpdateUserEmail(user.UserID, strings.ToLower(*email)); err != nil {
|
||||
if err := store.UpdateUserEmail(targetID, *email); err != nil {
|
||||
return fmt.Errorf("failed to update email: %w", err)
|
||||
}
|
||||
|
||||
@@ -504,61 +479,46 @@ func runUserSetEmail(args []string) error {
|
||||
}
|
||||
|
||||
func runUserSetUsername(args []string) error {
|
||||
fs := flag.NewFlagSet("user set-username", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user set-username")
|
||||
current := fs.String("current", "", "Current username (required)")
|
||||
new := fs.String("new", "", "New username (required)")
|
||||
|
||||
newName := fs.String("new", "", "New username (required)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *current == "" {
|
||||
return fmt.Errorf("current username required")
|
||||
}
|
||||
if *new == "" {
|
||||
if *newName == "" {
|
||||
return fmt.Errorf("new username required")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Get user
|
||||
user, err := store.GetUserByUsername(*current)
|
||||
targetID, err := lookupUser(store, *current, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", *current)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update username
|
||||
if err := store.UpdateUserUsername(user.UserID, strings.ToLower(*new)); err != nil {
|
||||
if err := store.UpdateUserUsername(targetID, *newName); err != nil {
|
||||
return fmt.Errorf("failed to update username: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Username updated: %s -> %s\n", *current, *new)
|
||||
fmt.Printf("Username updated: %s -> %s\n", *current, strings.ToLower(*newName))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runUserList(args []string) error {
|
||||
fs := flag.NewFlagSet("user list", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
|
||||
fs, dsn := newFlagSet("user list")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
@@ -574,8 +534,8 @@ func runUserList(args []string) error {
|
||||
|
||||
// Print results in tabular format
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "User ID\tUsername\tType\tEmail\tCreated\tExpires\tLast Login")
|
||||
fmt.Fprintln(w, strings.Repeat("-", 120))
|
||||
fmt.Fprintln(w, "User ID\tUsername\tEmail\tCreated\tLast Login")
|
||||
fmt.Fprintln(w, strings.Repeat("-", 100))
|
||||
|
||||
for _, u := range users {
|
||||
lastLogin := "never"
|
||||
@@ -586,17 +546,11 @@ func runUserList(args []string) error {
|
||||
if email == "" {
|
||||
email = "(none)"
|
||||
}
|
||||
expires := "never"
|
||||
if u.ExpiresAt != nil {
|
||||
expires = u.ExpiresAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
abbreviateID(u.UserID),
|
||||
u.Username,
|
||||
u.AccountType,
|
||||
email,
|
||||
u.CreatedAt.Format("2006-01-02 15:04"),
|
||||
expires,
|
||||
lastLogin,
|
||||
)
|
||||
}
|
||||
|
||||
+58
-19
@@ -41,12 +41,17 @@ func main() {
|
||||
apiHost = flag.String("api-host", "localhost", "API server host")
|
||||
apiPort = flag.Int("api-port", 8080, "API server port")
|
||||
dev = flag.Bool("dev", false, "Development mode (relaxed rate limits)")
|
||||
storagePath = flag.String("storage-path", "", "Path to SQLite database file (disables persistence if empty)")
|
||||
dsn = flag.String("dsn", "", "PostgreSQL connection string (default $CHESS_DSN; persistence is disabled if empty)")
|
||||
pidPath = flag.String("pid", "", "Optional path to write PID file")
|
||||
pidLock = flag.Bool("pid-lock", false, "Lock PID file to allow only one instance (requires -pid)")
|
||||
logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, or error")
|
||||
logHTTP = flag.Bool("log-http", true, "Log HTTP requests")
|
||||
proxies = flag.String("trusted-proxies", "", "Comma-separated reverse-proxy IPs/CIDRs whose -proxy-header is trusted for client IPs")
|
||||
proxyHeader = flag.String("proxy-header", "X-Real-IP", "Header carrying the client IP from a trusted proxy")
|
||||
finishedTTL = flag.Duration("finished-game-ttl", service.FinishedGameTTL, "How long completed games remain in memory (0 disables eviction)")
|
||||
anonTTL = flag.Duration("anonymous-game-ttl", service.AnonymousGameTTL, "How long games without a registered player survive after their last activity, in memory and in the database (0 keeps them)")
|
||||
maxUsers = flag.Int("max-users", service.DefaultMaxUsers, "Accounts at which public registration closes (0 = no limit; CLI-created accounts are not limited)")
|
||||
jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)")
|
||||
|
||||
// Web UI server flags
|
||||
serve = flag.Bool("serve", false, "Enable web UI server")
|
||||
@@ -55,6 +60,14 @@ func main() {
|
||||
webAPIURL = flag.String("web-api-url", "", "Browser-visible API base URL (defaults to the API listen address)")
|
||||
)
|
||||
flag.Parse()
|
||||
// Environment fallbacks are applied after parsing so -h never prints a
|
||||
// DSN, which may contain a password.
|
||||
if *dsn == "" {
|
||||
*dsn = os.Getenv("CHESS_DSN")
|
||||
}
|
||||
if *jwtFile == "" {
|
||||
*jwtFile = os.Getenv("CHESS_JWT_SECRET_FILE")
|
||||
}
|
||||
|
||||
var level slog.Level
|
||||
if err := level.UnmarshalText([]byte(*logLevel)); err != nil {
|
||||
@@ -73,6 +86,11 @@ func main() {
|
||||
// handler. Avoid embedding a second timestamp inside its message.
|
||||
log.SetFlags(0)
|
||||
|
||||
trustedProxies, err := parseTrustedProxies(*proxies)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid -trusted-proxies: %v", err)
|
||||
}
|
||||
|
||||
// Validate PID flags
|
||||
if *pidLock && *pidPath == "" {
|
||||
log.Fatal("Error: -pid-lock flag requires the -pid flag to be set")
|
||||
@@ -88,12 +106,13 @@ func main() {
|
||||
log.Printf("PID file created at: %s (lock: %v)", *pidPath, *pidLock)
|
||||
}
|
||||
|
||||
// 1. Initialize Storage (optional)
|
||||
// 1. Initialize Storage (optional). The DSN is never logged: it may carry a
|
||||
// password when peer or .pgpass authentication is not used.
|
||||
var store *storage.Store
|
||||
if *storagePath != "" {
|
||||
log.Printf("Initializing persistent storage at: %s", *storagePath)
|
||||
if *dsn != "" {
|
||||
log.Printf("Initializing PostgreSQL storage")
|
||||
var err error
|
||||
store, err = storage.NewStore(*storagePath, *dev)
|
||||
store, err = storage.NewStore(*dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
}
|
||||
@@ -101,27 +120,37 @@ func main() {
|
||||
log.Fatalf("Failed to initialize schema: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Persistent storage disabled (use -storage-path to enable)")
|
||||
log.Printf("Persistent storage disabled (use -dsn or CHESS_DSN to enable)")
|
||||
}
|
||||
|
||||
// JWT secret management
|
||||
// JWT signing key: a key file keeps sessions valid across restarts; without
|
||||
// one, dev mode uses a fixed key and production generates a per-process key.
|
||||
var jwtSecret []byte
|
||||
if *dev {
|
||||
// Fixed secret in dev mode for testing consistency
|
||||
switch {
|
||||
case *jwtFile != "":
|
||||
var err error
|
||||
if jwtSecret, err = loadJWTSecret(*jwtFile); err != nil {
|
||||
log.Fatalf("Failed to load JWT secret: %v", err)
|
||||
}
|
||||
log.Printf("JWT secret loaded from file (sessions survive restarts)")
|
||||
case *dev:
|
||||
jwtSecret = []byte("dev-secret-minimum-32-characters-long")
|
||||
log.Printf("Using fixed JWT secret (dev mode)")
|
||||
} else {
|
||||
// Generate cryptographically secure secret
|
||||
default:
|
||||
jwtSecret = make([]byte, 32)
|
||||
if _, err := rand.Read(jwtSecret); err != nil {
|
||||
log.Fatalf("Failed to generate JWT secret: %v", err)
|
||||
}
|
||||
log.Printf("JWT secret generated (sessions valid until restart)")
|
||||
rand.Read(jwtSecret)
|
||||
log.Printf("JWT secret generated (sessions valid until restart; use -jwt-secret-file to persist)")
|
||||
}
|
||||
|
||||
// 2. Initialize the Service with optional storage and auth
|
||||
svc := service.New(store, jwtSecret)
|
||||
svc, err := service.New(store, jwtSecret)
|
||||
clear(jwtSecret) // the JWT manager keeps its own copy
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize service: %v", err)
|
||||
}
|
||||
svc.SetFinishedGameTTL(*finishedTTL)
|
||||
svc.SetAnonymousGameTTL(*anonTTL)
|
||||
svc.SetMaxUsers(*maxUsers)
|
||||
|
||||
// Start cleanup job for expired users/sessions
|
||||
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
|
||||
@@ -141,7 +170,12 @@ func main() {
|
||||
}
|
||||
|
||||
// 4. Initialize the Fiber App/HTTP Handler, injecting processor and service
|
||||
app := http.NewFiberApp(proc, svc, *dev, *logHTTP)
|
||||
app := http.NewFiberApp(proc, svc, http.Options{
|
||||
DevMode: *dev,
|
||||
LogRequests: *logHTTP,
|
||||
TrustedProxies: trustedProxies,
|
||||
ProxyHeader: *proxyHeader,
|
||||
})
|
||||
|
||||
// API Server configuration
|
||||
apiAddr := fmt.Sprintf("%s:%d", *apiHost, *apiPort)
|
||||
@@ -157,8 +191,13 @@ func main() {
|
||||
} else {
|
||||
log.Printf("Rate Limit: 10 requests/second per IP")
|
||||
}
|
||||
if *storagePath != "" {
|
||||
log.Printf("Storage: Enabled (%s)", *storagePath)
|
||||
if len(trustedProxies) > 0 {
|
||||
log.Printf("Client IP: %s from trusted proxies %v", *proxyHeader, trustedProxies)
|
||||
} else {
|
||||
log.Printf("Client IP: TCP peer (behind a reverse proxy, set -trusted-proxies or all clients share one rate limit)")
|
||||
}
|
||||
if store != nil {
|
||||
log.Printf("Storage: Enabled (PostgreSQL)")
|
||||
} else {
|
||||
log.Printf("Storage: Disabled (auth features unavailable)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseTrustedProxies validates a comma-separated list of IPs and CIDRs.
|
||||
// Fiber skips entries it cannot parse, which would silently disable trust.
|
||||
func parseTrustedProxies(value string) ([]string, error) {
|
||||
var proxies []string
|
||||
for _, entry := range strings.Split(value, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entry, "/") {
|
||||
if _, err := netip.ParsePrefix(entry); err != nil {
|
||||
return nil, fmt.Errorf("%q is not a valid CIDR", entry)
|
||||
}
|
||||
} else if _, err := netip.ParseAddr(entry); err != nil {
|
||||
return nil, fmt.Errorf("%q is not a valid IP address", entry)
|
||||
}
|
||||
proxies = append(proxies, entry)
|
||||
}
|
||||
return proxies, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTrustedProxies(t *testing.T) {
|
||||
got, err := parseTrustedProxies(" 10.0.0.1, 10.1.0.0/16,,::1 ")
|
||||
if err != nil || !slices.Equal(got, []string{"10.0.0.1", "10.1.0.0/16", "::1"}) {
|
||||
t.Fatalf("parse = %v, %v", got, err)
|
||||
}
|
||||
if got, err := parseTrustedProxies(""); err != nil || len(got) != 0 {
|
||||
t.Fatalf("empty = %v, %v", got, err)
|
||||
}
|
||||
for _, invalid := range []string{"10.0.0", "10.0.0.0/33", "proxy.local"} {
|
||||
if _, err := parseTrustedProxies(invalid); err == nil {
|
||||
t.Errorf("%q accepted", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
minJWTSecretLen = 32
|
||||
maxJWTSecretLen = 4096
|
||||
)
|
||||
|
||||
// loadJWTSecret reads a signing key file. The file must be a regular file
|
||||
// readable only by its owner, like an SSH private key; trailing line endings
|
||||
// are ignored so `openssl rand -base64 48 > file` works as-is.
|
||||
func loadJWTSecret(path string) ([]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open JWT secret file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat JWT secret file: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("JWT secret file %s is not a regular file", path)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm&0o077 != 0 {
|
||||
return nil, fmt.Errorf("JWT secret file %s has mode %04o; it must not be accessible by group or others (chmod 600)",
|
||||
path, perm)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxJWTSecretLen+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read JWT secret file: %w", err)
|
||||
}
|
||||
if len(data) > maxJWTSecretLen {
|
||||
return nil, fmt.Errorf("JWT secret file %s exceeds %d bytes", path, maxJWTSecretLen)
|
||||
}
|
||||
secret := bytes.TrimRight(data, "\r\n")
|
||||
if len(secret) < minJWTSecretLen {
|
||||
return nil, fmt.Errorf("JWT secret file %s must contain at least %d bytes", path, minJWTSecretLen)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadJWTSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, content string, mode os.FileMode) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil { // umask-independent
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
key := strings.Repeat("k", 48)
|
||||
|
||||
secret, err := loadJWTSecret(write("ok", key+"\n", 0o600))
|
||||
if err != nil || string(secret) != key {
|
||||
t.Fatalf("valid secret = %q, %v", secret, err)
|
||||
}
|
||||
for name, test := range map[string]struct {
|
||||
content string
|
||||
mode os.FileMode
|
||||
want string
|
||||
}{
|
||||
"group readable": {key, 0o640, "chmod 600"},
|
||||
"short": {strings.Repeat("k", 31) + "\n", 0o600, "at least 32 bytes"},
|
||||
"oversized": {strings.Repeat("k", maxJWTSecretLen+1), 0o600, "exceeds"},
|
||||
} {
|
||||
if _, err := loadJWTSecret(write(name, test.content, test.mode)); err == nil ||
|
||||
!strings.Contains(err.Error(), test.want) {
|
||||
t.Errorf("%s: error = %v, want %q", name, err, test.want)
|
||||
}
|
||||
}
|
||||
if _, err := loadJWTSecret(dir); err == nil {
|
||||
t.Error("directory accepted as secret file")
|
||||
}
|
||||
if _, err := loadJWTSecret(filepath.Join(dir, "missing")); err == nil {
|
||||
t.Error("missing file accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Deployment Scripts
|
||||
|
||||
Run in this order; each script's header documents its inputs. The full guide is
|
||||
[doc/deployment.md](../doc/deployment.md).
|
||||
|
||||
| Order | Where | Script | Purpose |
|
||||
|---|---|---|---|
|
||||
| 1 | Bastille host | `freebsd/host.sh <jail>` | `sysvshm=new` for PostgreSQL, jail restart |
|
||||
| 2 | Build machine | `make server-freebsd` | Static `bin/chess-server-freebsd-amd64` |
|
||||
| 3 | Jail (root) | `freebsd/setup-jail.sh` | PostgreSQL 18, database, account, service, backups, optional SQLite import |
|
||||
| 4 | Bastille host | `freebsd/nginx-chess.conf` | Location block for the host nginx |
|
||||
|
||||
Supporting files, installed by `setup-jail.sh`:
|
||||
|
||||
- `postgresql/setup.sql` — role, database, and schema (owner or split mode)
|
||||
- `postgresql/migrate-sqlite.sh` — one-time import of a v0.11 SQLite database
|
||||
- `freebsd/rc.d/chess_server` — `daemon(8)` service script
|
||||
- `freebsd/chess-backup.sh` — nightly `pg_dump` with 14-day retention
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Nightly logical backup of the chess database. setup-jail.sh installs this as
|
||||
# /usr/local/sbin/chess-backup and schedules it as the postgres user through
|
||||
# /usr/local/etc/cron.d/chess-backup.
|
||||
#
|
||||
# CHESS_BACKUP_DIR destination (default: /var/db/postgres/backups)
|
||||
# CHESS_BACKUP_KEEP days of dumps to keep (default: 14)
|
||||
#
|
||||
# Restore into a freshly provisioned database with:
|
||||
# su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'
|
||||
# (use --role=chess_owner for the split-privilege setup).
|
||||
set -eu
|
||||
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
|
||||
|
||||
dir=${CHESS_BACKUP_DIR:-/var/db/postgres/backups}
|
||||
keep=${CHESS_BACKUP_KEEP:-14}
|
||||
umask 077
|
||||
mkdir -p "$dir"
|
||||
|
||||
target="$dir/chess-$(date -u +%Y%m%dT%H%M%SZ).dump"
|
||||
pg_dump --format=custom --dbname=chess --file="$target.partial"
|
||||
mv "$target.partial" "$target"
|
||||
find "$dir" -name 'chess-*.dump' -type f -mtime "+$keep" -delete
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# Host-side preparation for the chess jail. Run as root on the Bastille host:
|
||||
#
|
||||
# sh deploy/freebsd/host.sh <jail-name>
|
||||
#
|
||||
# PostgreSQL allocates a small System V shared-memory segment. This gives the
|
||||
# jail its own SysV shared-memory namespace (isolated, unlike the older
|
||||
# allow.sysvipc) and restarts the jail so the parameter takes effect. The
|
||||
# restart stops every service in the jail, including a running chess-server.
|
||||
#
|
||||
# After setup-jail.sh has run inside the jail, add the location block from
|
||||
# deploy/freebsd/nginx-chess.conf to the host nginx configuration, then
|
||||
# nginx -t && service nginx reload
|
||||
set -eu
|
||||
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
|
||||
|
||||
[ $# -eq 1 ] || { echo "usage: $0 <jail-name>" >&2; exit 2; }
|
||||
jail=$1
|
||||
[ "$(id -u)" -eq 0 ] || { echo "host.sh: run as root" >&2; exit 1; }
|
||||
command -v bastille >/dev/null 2>&1 || { echo "host.sh: bastille not found" >&2; exit 1; }
|
||||
|
||||
bastille config "$jail" set sysvshm new
|
||||
bastille restart "$jail"
|
||||
echo "host.sh: $jail now has sysvshm=new; run deploy/freebsd/setup-jail.sh inside it"
|
||||
@@ -0,0 +1,19 @@
|
||||
# Host nginx: proxy /chess/ to the chess API in the jail. Include inside the
|
||||
# existing TLS server block, next to the security headers. Replace
|
||||
# JAIL_IP:API_PORT with the values given to setup-jail.sh.
|
||||
#
|
||||
# chess-server trusts X-Real-IP only from the address in -trusted-proxies
|
||||
# (TRUSTED_PROXIES in setup-jail.sh): the address this nginx uses to reach
|
||||
# the jail. Do not forward X-Forwarded-For for rate limiting; its first entry
|
||||
# is supplied by the client.
|
||||
location /chess/ {
|
||||
proxy_pass http://JAIL_IP:API_PORT/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# Long-polling waits up to 30 seconds for a game update.
|
||||
proxy_read_timeout 45s;
|
||||
# Bound request bodies; API payloads are small JSON documents.
|
||||
client_max_body_size 16k;
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# PROVIDE: chess_server
|
||||
# REQUIRE: LOGIN postgresql
|
||||
# KEYWORD: shutdown
|
||||
#
|
||||
# rc.conf variables (setup-jail.sh sets them with sysrc):
|
||||
# chess_server_enable YES to start at boot
|
||||
# chess_server_flags chess-server arguments (-dsn, -jwt-secret-file, ...)
|
||||
# chess_server_env environment, including PATH=... for stockfish
|
||||
# chess_server_account unprivileged account (default: chess)
|
||||
# chess_server_binary default: /usr/local/chess/chess-server
|
||||
# chess_server_logfile default: /var/log/chess/chess-server.log
|
||||
#
|
||||
# daemon(8) starts as root, writes the supervisor pidfile, drops to the
|
||||
# account, restarts the server if it exits (-r, 5 s delay), and reopens the
|
||||
# log file on SIGHUP (-H) for newsyslog. Do not set chess_server_user: rc.subr
|
||||
# would switch users before daemon runs and daemon could not drop privileges.
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name=chess_server
|
||||
rcvar=chess_server_enable
|
||||
load_rc_config $name
|
||||
|
||||
: ${chess_server_enable:="NO"}
|
||||
: ${chess_server_account:="chess"}
|
||||
: ${chess_server_binary:="/usr/local/chess/chess-server"}
|
||||
: ${chess_server_logfile:="/var/log/chess/chess-server.log"}
|
||||
|
||||
pidfile="/var/run/${name}.pid"
|
||||
procname="daemon"
|
||||
command="/usr/sbin/daemon"
|
||||
command_args="-f -r -R 5 -H -P ${pidfile} -o ${chess_server_logfile} -u ${chess_server_account} ${chess_server_binary} ${chess_server_flags}"
|
||||
|
||||
run_rc_command "$1"
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/bin/sh
|
||||
# Install or update chess-server with PostgreSQL 18 inside a FreeBSD jail.
|
||||
# Run as root inside the jail, from a copy of this repository:
|
||||
#
|
||||
# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \
|
||||
# API_HOST=10.17.89.10 TRUSTED_PROXIES=10.17.89.1 \
|
||||
# sh deploy/freebsd/setup-jail.sh
|
||||
#
|
||||
# Before the first run:
|
||||
# 1. On the host: sh deploy/freebsd/host.sh <jail> (sysvshm=new, restart)
|
||||
# 2. On a build machine: make server-freebsd, then copy
|
||||
# bin/chess-server-freebsd-amd64 into the jail
|
||||
# 3. For an existing SQLite deployment: note the database path; the old
|
||||
# service is stopped before the import
|
||||
#
|
||||
# Required:
|
||||
# CHESS_BINARY new chess-server binary (FreeBSD/amd64, static)
|
||||
# API_HOST address chess-server listens on (the jail IP)
|
||||
#
|
||||
# Optional:
|
||||
# API_PORT default 8080
|
||||
# TRUSTED_PROXIES host nginx address(es) as seen from the jail; without it
|
||||
# all proxied clients share one rate-limit bucket
|
||||
# CHESS_HOME default: the chess account's home, else /usr/local/chess
|
||||
# LOG_DIR default /var/log/chess
|
||||
# SQLITE_DB v0.11 SQLite database to import once into an empty schema
|
||||
# OLD_SERVICE rc.d name of the previous chess service to stop and
|
||||
# disable (for example chess); skipped when unset
|
||||
# SPLIT_PRIVILEGES yes: runtime role gets DML only, schema owned by
|
||||
# chess_owner (default no; see deploy/postgresql/setup.sql)
|
||||
# EXTRA_FLAGS additional chess-server flags (e.g. "-max-users 500")
|
||||
# PGDATA default /var/db/postgres/data18
|
||||
#
|
||||
# The script is idempotent: rerun it to upgrade the binary, change flags, or
|
||||
# repair configuration. It never drops data. Steps:
|
||||
# packages -> PostgreSQL init and hardening -> role/database/schema ->
|
||||
# chess account, binary, JWT key, log dir -> stop old service -> schema
|
||||
# migration -> optional SQLite import -> rc.d/rc.conf -> log rotation ->
|
||||
# nightly backup -> start and health check
|
||||
set -eu
|
||||
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
|
||||
umask 022
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
repo=$(cd "$here/../.." && pwd)
|
||||
|
||||
: "${CHESS_BINARY:?set CHESS_BINARY to the new chess-server binary}"
|
||||
: "${API_HOST:?set API_HOST to the address chess-server listens on}"
|
||||
API_PORT=${API_PORT:-8080}
|
||||
TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
|
||||
LOG_DIR=${LOG_DIR:-/var/log/chess}
|
||||
SQLITE_DB=${SQLITE_DB:-}
|
||||
OLD_SERVICE=${OLD_SERVICE:-}
|
||||
SPLIT_PRIVILEGES=${SPLIT_PRIVILEGES:-no}
|
||||
EXTRA_FLAGS=${EXTRA_FLAGS:-}
|
||||
PGDATA=${PGDATA:-/var/db/postgres/data18}
|
||||
# Install locations; overridable only for testing the script.
|
||||
RC_DIR=${RC_DIR:-/usr/local/etc/rc.d}
|
||||
NEWSYSLOG_DIR=${NEWSYSLOG_DIR:-/usr/local/etc/newsyslog.conf.d}
|
||||
CRON_DIR=${CRON_DIR:-/usr/local/etc/cron.d}
|
||||
SBIN_DIR=${SBIN_DIR:-/usr/local/sbin}
|
||||
BACKUP_DIR=${BACKUP_DIR:-/var/db/postgres/backups}
|
||||
|
||||
# The database role is named after the OS account: peer authentication maps
|
||||
# one to the other, so no password exists anywhere.
|
||||
account=chess
|
||||
dsn='postgres:///chess?host=/tmp'
|
||||
|
||||
log() { printf '==> %s\n' "$*"; }
|
||||
die() { printf 'setup-jail: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Run SQL from stdin as the postgres superuser against database $1.
|
||||
pg_sql() {
|
||||
su -m postgres -c "psql -X -A -t -q -v ON_ERROR_STOP=1 -d $1"
|
||||
}
|
||||
|
||||
# --- preflight ---------------------------------------------------------------
|
||||
[ "$(id -u)" -eq 0 ] || die "run as root"
|
||||
[ "$(uname -s)" = FreeBSD ] || die "this script targets FreeBSD"
|
||||
[ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY"
|
||||
for f in "$repo/deploy/postgresql/setup.sql" "$repo/deploy/postgresql/migrate-sqlite.sh" \
|
||||
"$here/rc.d/chess_server" "$here/chess-backup.sh"; do
|
||||
[ -f "$f" ] || die "missing $f; run from a complete repository copy"
|
||||
done
|
||||
case $SPLIT_PRIVILEGES in yes | no) ;; *) die "SPLIT_PRIVILEGES must be yes or no" ;; esac
|
||||
if [ -n "$SQLITE_DB" ] && [ ! -r "$SQLITE_DB" ]; then
|
||||
die "SQLITE_DB not readable: $SQLITE_DB"
|
||||
fi
|
||||
[ -n "$TRUSTED_PROXIES" ] ||
|
||||
log "WARNING: TRUSTED_PROXIES unset; behind nginx every client shares one rate limit"
|
||||
|
||||
# --- packages ----------------------------------------------------------------
|
||||
packages="postgresql18-server postgresql18-client stockfish"
|
||||
[ -n "$SQLITE_DB" ] && packages="$packages sqlite3"
|
||||
log "installing packages: $packages"
|
||||
# shellcheck disable=SC2086 # word splitting intended
|
||||
pkg install -y $packages
|
||||
|
||||
# --- PostgreSQL ----------------------------------------------------------------
|
||||
sysrc -q postgresql_enable=YES >/dev/null
|
||||
sysrc -q postgresql_data="$PGDATA" >/dev/null
|
||||
sysrc -q postgresql_initdb_flags="--encoding=UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8 --auth-local=peer --auth-host=reject" >/dev/null
|
||||
|
||||
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||
log "initializing PostgreSQL cluster in $PGDATA"
|
||||
if ! service postgresql initdb; then
|
||||
die "initdb failed. If it reports a shared memory error, run deploy/freebsd/host.sh <jail> on the host (sysvshm=new) and retry"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Only the postgres superuser and the chess role, each over the Unix socket
|
||||
# with peer authentication. No host lines: TCP is disabled below.
|
||||
hba="$PGDATA/pg_hba.conf"
|
||||
if ! grep -q '^# Managed by chess setup-jail.sh' "$hba" 2>/dev/null; then
|
||||
[ -f "$hba.orig" ] || cp -p "$hba" "$hba.orig"
|
||||
log "writing $hba (original kept as pg_hba.conf.orig)"
|
||||
cat >"$hba" <<'HBA'
|
||||
# Managed by chess setup-jail.sh; edits are kept on rerun.
|
||||
# TYPE DATABASE USER METHOD
|
||||
local all postgres peer
|
||||
local chess chess peer
|
||||
HBA
|
||||
chown postgres:postgres "$hba"
|
||||
chmod 600 "$hba"
|
||||
fi
|
||||
|
||||
log "starting PostgreSQL"
|
||||
service postgresql status >/dev/null 2>&1 || service postgresql start
|
||||
# ALTER SYSTEM writes postgresql.auto.conf, leaving postgresql.conf untouched.
|
||||
pg_sql postgres <<'SQL'
|
||||
ALTER SYSTEM SET listen_addresses = '';
|
||||
ALTER SYSTEM SET unix_socket_directories = '/tmp';
|
||||
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
|
||||
SQL
|
||||
# listen_addresses and unix_socket_directories apply only after a restart.
|
||||
service postgresql restart
|
||||
|
||||
if [ -z "$(echo "SELECT 1 FROM pg_roles WHERE rolname = 'chess'" | pg_sql postgres)" ]; then
|
||||
log "provisioning role, database, and schema (split privileges: $SPLIT_PRIVILEGES)"
|
||||
split=false
|
||||
[ "$SPLIT_PRIVILEGES" = yes ] && split=true
|
||||
su -m postgres -c "psql -X -q -d postgres -v split=$split -f -" <"$repo/deploy/postgresql/setup.sql"
|
||||
else
|
||||
log "role chess exists; leaving database provisioning unchanged"
|
||||
fi
|
||||
|
||||
# --- chess account and files ---------------------------------------------------
|
||||
if ! id "$account" >/dev/null 2>&1; then
|
||||
CHESS_HOME=${CHESS_HOME:-/usr/local/chess}
|
||||
log "creating account $account (home $CHESS_HOME, no shell, no password)"
|
||||
pw useradd -n "$account" -c "Chess server" -d "$CHESS_HOME" -s /usr/sbin/nologin -w no
|
||||
else
|
||||
CHESS_HOME=${CHESS_HOME:-$(pw usershow "$account" | cut -d: -f9)}
|
||||
shell=$(pw usershow "$account" | cut -d: -f10)
|
||||
case $shell in
|
||||
*/nologin | */false) ;;
|
||||
*) log "WARNING: $account has login shell $shell; consider: pw usermod $account -s /usr/sbin/nologin" ;;
|
||||
esac
|
||||
fi
|
||||
[ -d "$CHESS_HOME" ] || install -d -o root -g "$account" -m 0750 "$CHESS_HOME"
|
||||
install -d -o "$account" -g "$account" -m 0750 "$LOG_DIR"
|
||||
|
||||
binary="$CHESS_HOME/chess-server"
|
||||
if [ -f "$binary" ] && ! cmp -s "$CHESS_BINARY" "$binary"; then
|
||||
log "keeping the previous binary as $binary.prev"
|
||||
cp -p "$binary" "$binary.prev"
|
||||
fi
|
||||
# Root-owned: the service account cannot replace its own executable.
|
||||
install -o root -g wheel -m 0555 "$CHESS_BINARY" "$binary"
|
||||
|
||||
key="$CHESS_HOME/jwt.key"
|
||||
if [ ! -f "$key" ]; then
|
||||
log "generating JWT signing key $key"
|
||||
(umask 077 && openssl rand -base64 48 >"$key")
|
||||
fi
|
||||
chown "$account:$account" "$key"
|
||||
chmod 600 "$key"
|
||||
|
||||
# --- stop the previous service ------------------------------------------------
|
||||
if [ -n "$OLD_SERVICE" ] && [ "$OLD_SERVICE" != chess_server ]; then
|
||||
log "stopping and disabling previous service $OLD_SERVICE"
|
||||
service "$OLD_SERVICE" onestop || true
|
||||
sysrc -q "${OLD_SERVICE}_enable=NO" >/dev/null || true
|
||||
fi
|
||||
service chess_server onestatus >/dev/null 2>&1 && service chess_server onestop
|
||||
|
||||
# --- schema ------------------------------------------------------------------
|
||||
log "creating or migrating the chess schema"
|
||||
if [ "$SPLIT_PRIVILEGES" = yes ]; then
|
||||
# The postgres account migrates as chess_owner. It may not be able to
|
||||
# traverse the chess home, so it runs a temporary copy of the binary.
|
||||
migrator=$(mktemp /tmp/chess-migrate.XXXXXX)
|
||||
install -o root -g wheel -m 0555 "$CHESS_BINARY" "$migrator"
|
||||
su -m postgres -c "$migrator db init -dsn \"dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'\"" ||
|
||||
{ rm -f "$migrator"; die "schema migration failed"; }
|
||||
rm -f "$migrator"
|
||||
else
|
||||
su -m "$account" -c "$binary db init -dsn '$dsn'"
|
||||
fi
|
||||
|
||||
# --- optional SQLite import ----------------------------------------------------
|
||||
if [ -n "$SQLITE_DB" ]; then
|
||||
if [ -n "$(echo 'SELECT 1 FROM chess.users UNION ALL SELECT 1 FROM chess.games LIMIT 1' | pg_sql chess)" ]; then
|
||||
log "chess schema already has data; skipping SQLite import"
|
||||
else
|
||||
log "importing $SQLITE_DB"
|
||||
work=$(mktemp -d /tmp/chess-import.XXXXXX)
|
||||
# Copy WAL companions too: after an unclean stop they hold recent writes.
|
||||
for suffix in "" -wal -shm; do
|
||||
if [ -f "$SQLITE_DB$suffix" ]; then
|
||||
cp "$SQLITE_DB$suffix" "$work/legacy.db$suffix"
|
||||
fi
|
||||
done
|
||||
chown -R postgres "$work"
|
||||
su -m postgres -c "cd '$work' && CHESS_SCHEMA=chess sh -s -- '$work/legacy.db' dbname=chess" \
|
||||
<"$repo/deploy/postgresql/migrate-sqlite.sh"
|
||||
rm -rf "$work"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- service configuration -------------------------------------------------------
|
||||
if [ -f "$RC_DIR/chess_server" ] && ! cmp -s "$here/rc.d/chess_server" "$RC_DIR/chess_server"; then
|
||||
cp -p "$RC_DIR/chess_server" "$RC_DIR/chess_server.orig"
|
||||
fi
|
||||
install -d "$RC_DIR"
|
||||
install -o root -g wheel -m 0555 "$here/rc.d/chess_server" "$RC_DIR/chess_server"
|
||||
|
||||
flags="-api-host $API_HOST -api-port $API_PORT -dsn $dsn -jwt-secret-file $key -log-level info"
|
||||
[ -n "$TRUSTED_PROXIES" ] && flags="$flags -trusted-proxies $TRUSTED_PROXIES"
|
||||
[ -n "$EXTRA_FLAGS" ] && flags="$flags $EXTRA_FLAGS"
|
||||
sysrc -q chess_server_enable=YES >/dev/null
|
||||
sysrc -q chess_server_account="$account" >/dev/null
|
||||
sysrc -q chess_server_binary="$binary" >/dev/null
|
||||
sysrc -q chess_server_logfile="$LOG_DIR/chess-server.log" >/dev/null
|
||||
sysrc -q chess_server_env="PATH=/usr/local/bin:/usr/bin:/bin" >/dev/null
|
||||
sysrc -q chess_server_flags="$flags" >/dev/null
|
||||
|
||||
# Rotate daily or at 1 MB, keep a week; SIGHUP makes daemon(8) reopen the log.
|
||||
install -d "$NEWSYSLOG_DIR"
|
||||
printf '%s\n' \
|
||||
"# Managed by chess setup-jail.sh" \
|
||||
"$LOG_DIR/chess-server.log $account:$account 640 7 1000 @T00 JC /var/run/chess_server.pid 1" \
|
||||
>"$NEWSYSLOG_DIR/chess_server.conf"
|
||||
|
||||
# Nightly pg_dump at 03:30, as postgres, keeping 14 days.
|
||||
install -d "$SBIN_DIR"
|
||||
install -o root -g wheel -m 0555 "$here/chess-backup.sh" "$SBIN_DIR/chess-backup"
|
||||
install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR"
|
||||
install -d "$CRON_DIR"
|
||||
printf '%s\n' \
|
||||
"# Managed by chess setup-jail.sh" \
|
||||
"SHELL=/bin/sh" \
|
||||
"PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin" \
|
||||
"30 3 * * * postgres CHESS_BACKUP_DIR=$BACKUP_DIR $SBIN_DIR/chess-backup" \
|
||||
>"$CRON_DIR/chess-backup"
|
||||
|
||||
# --- start and verify -----------------------------------------------------------
|
||||
log "starting chess_server"
|
||||
service chess_server start
|
||||
health=""
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if health=$(fetch -q -T 2 -o - "http://$API_HOST:$API_PORT/health" 2>/dev/null); then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
case $health in
|
||||
*'"storage":"ok"'*) log "healthy: $health" ;;
|
||||
*) die "chess-server did not report healthy storage (got: ${health:-no response}); see $LOG_DIR/chess-server.log" ;;
|
||||
esac
|
||||
|
||||
cat <<EOF
|
||||
|
||||
chess-server is running at http://$API_HOST:$API_PORT with PostgreSQL storage.
|
||||
|
||||
Remaining host step: add the location block from deploy/freebsd/nginx-chess.conf
|
||||
(proxy_pass http://$API_HOST:$API_PORT/) to the host nginx, then
|
||||
nginx -t && service nginx reload
|
||||
|
||||
Administration inside the jail:
|
||||
su -m $account -c '$binary db user list -dsn "$dsn"'
|
||||
service chess_server status|restart
|
||||
tail -f $LOG_DIR/chess-server.log
|
||||
EOF
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/bin/sh
|
||||
# One-time import of a chess SQLite database (schema v2, chess <= v0.11) into
|
||||
# the PostgreSQL schema created by `chess-server db init`.
|
||||
#
|
||||
# Usage: migrate-sqlite.sh /path/to/chess.db [psql-conninfo]
|
||||
#
|
||||
# conninfo libpq connection string for the chess database; defaults to
|
||||
# $CHESS_DSN. Run as a role that can write the chess tables and
|
||||
# create temporary tables (the postgres superuser is simplest).
|
||||
# CHESS_SCHEMA schema holding the chess tables (default: chess)
|
||||
#
|
||||
# Stop the old server first so the SQLite WAL is checkpointed. The import runs
|
||||
# in one transaction and refuses a target that already holds users or games.
|
||||
#
|
||||
# - Every account is imported as a regular account; the old temporary/permanent
|
||||
# distinction and account expiry no longer exist.
|
||||
# - Expired sessions are dropped.
|
||||
# - Legacy games recorded before claim columns existed get claims backfilled
|
||||
# from player IDs that match an imported user, and every claim records the
|
||||
# claimant's username.
|
||||
# - Games without a claim are imported too; the server deletes them once they
|
||||
# have been idle for -anonymous-game-ttl (24 hours by default).
|
||||
set -eu
|
||||
|
||||
die() {
|
||||
echo "migrate-sqlite: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ $# -ge 1 ] && [ $# -le 2 ] || die "usage: $0 /path/to/chess.db [psql-conninfo]"
|
||||
sqlite_db=$1
|
||||
conninfo=${2:-${CHESS_DSN:-}}
|
||||
schema=${CHESS_SCHEMA:-chess}
|
||||
|
||||
for tool in sqlite3 psql; do
|
||||
command -v "$tool" >/dev/null 2>&1 || die "$tool is required"
|
||||
done
|
||||
[ -r "$sqlite_db" ] || die "cannot read $sqlite_db"
|
||||
[ -n "$conninfo" ] || die "no connection string: pass one or set CHESS_DSN"
|
||||
|
||||
version=$(sqlite3 -readonly "$sqlite_db" 'PRAGMA user_version;')
|
||||
[ "$version" = 2 ] || die "SQLite schema version is $version; start chess v0.11 on it once to upgrade to version 2"
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT INT TERM
|
||||
|
||||
export_table() {
|
||||
sqlite3 -readonly -csv -nullvalue '\N' "$sqlite_db" "$2" >"$work/$1.csv"
|
||||
}
|
||||
export_table users 'SELECT user_id, username, email, password_hash, account_type,
|
||||
created_at, expires_at, last_login_at FROM users'
|
||||
export_table sessions 'SELECT session_id, user_id, created_at, expires_at FROM sessions'
|
||||
export_table games 'SELECT game_id, initial_fen,
|
||||
white_player_id, white_type, white_level, white_search_time, white_claimed_by,
|
||||
black_player_id, black_type, black_level, black_search_time, black_claimed_by,
|
||||
start_time_utc, result, end_time_utc FROM games'
|
||||
export_table moves 'SELECT game_id, move_number, move_uci, fen_after_move,
|
||||
player_color, move_time_utc FROM moves'
|
||||
|
||||
# psql's \copy paths are resolved relative to the working directory.
|
||||
cd "$work"
|
||||
psql -X -q -v ON_ERROR_STOP=1 -v schema="$schema" "$conninfo" <<'SQL'
|
||||
BEGIN;
|
||||
SET LOCAL search_path TO :"schema";
|
||||
-- Legacy CURRENT_TIMESTAMP defaults carry no offset and were written in UTC.
|
||||
SET LOCAL TIME ZONE 'UTC';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Separate statements: PL/pgSQL plans each one only when it is reached.
|
||||
IF to_regclass('schema_version') IS NULL THEN
|
||||
RAISE EXCEPTION 'no chess schema in search_path; run chess-server db init first';
|
||||
END IF;
|
||||
IF (SELECT version FROM schema_version) <> 1 THEN
|
||||
RAISE EXCEPTION 'target schema must be at version 1';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM users) OR EXISTS (SELECT 1 FROM games) THEN
|
||||
RAISE EXCEPTION 'target already contains chess data; import only into an empty schema';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TEMP TABLE import_users (
|
||||
user_id text, username text, email text, password_hash text, account_type text,
|
||||
created_at text, expires_at text, last_login_at text
|
||||
) ON COMMIT DROP;
|
||||
CREATE TEMP TABLE import_sessions (
|
||||
session_id text, user_id text, created_at text, expires_at text
|
||||
) ON COMMIT DROP;
|
||||
CREATE TEMP TABLE import_games (
|
||||
game_id text, initial_fen text,
|
||||
white_player_id text, white_type smallint, white_level smallint, white_search_time integer,
|
||||
white_claimed_by text,
|
||||
black_player_id text, black_type smallint, black_level smallint, black_search_time integer,
|
||||
black_claimed_by text,
|
||||
start_time_utc text, result text, end_time_utc text
|
||||
) ON COMMIT DROP;
|
||||
CREATE TEMP TABLE import_moves (
|
||||
game_id text, move_number integer, move_uci text, fen_after_move text,
|
||||
player_color text, move_time_utc text
|
||||
) ON COMMIT DROP;
|
||||
|
||||
\copy import_users FROM 'users.csv' WITH (FORMAT csv, NULL '\N')
|
||||
\copy import_sessions FROM 'sessions.csv' WITH (FORMAT csv, NULL '\N')
|
||||
\copy import_games FROM 'games.csv' WITH (FORMAT csv, NULL '\N')
|
||||
\copy import_moves FROM 'moves.csv' WITH (FORMAT csv, NULL '\N')
|
||||
|
||||
INSERT INTO users (user_id, username, email, password_hash, created_at, last_login_at)
|
||||
SELECT user_id::uuid, lower(username), NULLIF(lower(email), ''), password_hash,
|
||||
created_at::timestamptz, NULLIF(last_login_at, '')::timestamptz
|
||||
FROM import_users;
|
||||
|
||||
INSERT INTO sessions (session_id, user_id, created_at, expires_at)
|
||||
SELECT s.session_id::uuid, s.user_id::uuid, s.created_at::timestamptz, s.expires_at::timestamptz
|
||||
FROM import_sessions s
|
||||
WHERE s.expires_at::timestamptz > now()
|
||||
AND s.user_id IN (SELECT user_id FROM import_users);
|
||||
|
||||
-- A claim may be empty in legacy rows; before claim columns existed, an
|
||||
-- authenticated creator was recorded only as the slot's player ID. Names are
|
||||
-- filled from the imported users below.
|
||||
INSERT INTO games (game_id, initial_fen,
|
||||
white_player_id, white_type, white_level, white_search_time, white_claimed_by,
|
||||
black_player_id, black_type, black_level, black_search_time, black_claimed_by,
|
||||
start_time_utc, result, end_time_utc)
|
||||
SELECT g.game_id::uuid, g.initial_fen,
|
||||
g.white_player_id::uuid, g.white_type, g.white_level, g.white_search_time,
|
||||
COALESCE(NULLIF(g.white_claimed_by, '')::uuid,
|
||||
(SELECT u.user_id::uuid FROM import_users u WHERE u.user_id = g.white_player_id)),
|
||||
g.black_player_id::uuid, g.black_type, g.black_level, g.black_search_time,
|
||||
COALESCE(NULLIF(g.black_claimed_by, '')::uuid,
|
||||
(SELECT u.user_id::uuid FROM import_users u WHERE u.user_id = g.black_player_id)),
|
||||
g.start_time_utc::timestamptz,
|
||||
NULLIF(g.result, ''),
|
||||
CASE WHEN NULLIF(g.result, '') IS NOT NULL
|
||||
THEN COALESCE(NULLIF(g.end_time_utc, '')::timestamptz, g.start_time_utc::timestamptz)
|
||||
END
|
||||
FROM import_games g;
|
||||
|
||||
UPDATE games g SET
|
||||
white_name = (SELECT u.username FROM users u WHERE u.user_id = g.white_claimed_by),
|
||||
black_name = (SELECT u.username FROM users u WHERE u.user_id = g.black_claimed_by);
|
||||
|
||||
INSERT INTO moves (game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc)
|
||||
SELECT m.game_id::uuid, m.move_number, m.move_uci, m.fen_after_move, m.player_color,
|
||||
m.move_time_utc::timestamptz
|
||||
FROM import_moves m
|
||||
WHERE m.game_id IN (SELECT game_id FROM import_games);
|
||||
|
||||
-- Replay listings read the move count from the last move number, so every
|
||||
-- game's line must be numbered 1..n without gaps.
|
||||
DO $$
|
||||
DECLARE broken integer;
|
||||
BEGIN
|
||||
SELECT count(*) INTO broken FROM (
|
||||
SELECT game_id FROM moves GROUP BY game_id HAVING max(move_number) <> count(*)
|
||||
) gaps;
|
||||
IF broken > 0 THEN
|
||||
RAISE EXCEPTION '% game(s) have gaps in move numbering; import aborted', broken;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
SELECT format('imported: %s users (%s dropped), %s sessions (%s expired or orphaned), %s games (%s anonymous), %s moves (%s orphaned)',
|
||||
(SELECT count(*) FROM users), (SELECT count(*) FROM import_users) - (SELECT count(*) FROM users),
|
||||
(SELECT count(*) FROM sessions), (SELECT count(*) FROM import_sessions) - (SELECT count(*) FROM sessions),
|
||||
(SELECT count(*) FROM games),
|
||||
(SELECT count(*) FROM games WHERE white_claimed_by IS NULL AND black_claimed_by IS NULL),
|
||||
(SELECT count(*) FROM moves), (SELECT count(*) FROM import_moves) - (SELECT count(*) FROM moves)) AS summary
|
||||
\gset
|
||||
\echo :summary
|
||||
COMMIT;
|
||||
SQL
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Provision the chess database on PostgreSQL 18. Run once as a superuser:
|
||||
--
|
||||
-- psql -X -U postgres -d postgres -f setup.sql # owner mode
|
||||
-- psql -X -U postgres -d postgres -v split=true -f setup.sql # split mode
|
||||
--
|
||||
-- Owner mode (default): the login role `chess` owns schema `chess`, so the
|
||||
-- server applies migrations itself at startup. It has no other privileges.
|
||||
--
|
||||
-- Split mode: NOLOGIN role `chess_owner` owns the schema and tables; the login
|
||||
-- role `chess` receives only SELECT/INSERT/UPDATE/DELETE. The server starts
|
||||
-- only when the schema is current; an administrator migrates, as the OS user
|
||||
-- postgres, with
|
||||
-- chess-server db init -dsn "dbname=chess user=postgres \
|
||||
-- options='-c role=chess_owner -c search_path=chess'"
|
||||
--
|
||||
-- Neither mode sets a password: the chess OS user authenticates over the Unix
|
||||
-- socket with peer authentication (see doc/deployment.md for pg_hba.conf).
|
||||
\set ON_ERROR_STOP on
|
||||
\if :{?split}
|
||||
\else
|
||||
\set split false
|
||||
\endif
|
||||
|
||||
CREATE ROLE chess LOGIN
|
||||
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS
|
||||
CONNECTION LIMIT 20;
|
||||
|
||||
-- The builtin C.UTF-8 provider gives code-point ordering and Unicode case
|
||||
-- mapping that do not change with operating-system library upgrades.
|
||||
CREATE DATABASE chess
|
||||
OWNER postgres
|
||||
TEMPLATE template0
|
||||
ENCODING 'UTF8'
|
||||
LOCALE_PROVIDER builtin
|
||||
BUILTIN_LOCALE 'C.UTF-8';
|
||||
|
||||
REVOKE ALL ON DATABASE chess FROM PUBLIC;
|
||||
GRANT CONNECT ON DATABASE chess TO chess;
|
||||
|
||||
\connect chess
|
||||
|
||||
-- Nothing may be created in or read from public; chess lives in its own schema.
|
||||
REVOKE ALL ON SCHEMA public FROM PUBLIC;
|
||||
|
||||
\if :split
|
||||
CREATE ROLE chess_owner NOLOGIN
|
||||
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
||||
CREATE SCHEMA chess AUTHORIZATION chess_owner;
|
||||
GRANT USAGE ON SCHEMA chess TO chess;
|
||||
-- Tables created by future migrations inherit runtime DML grants.
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE chess_owner IN SCHEMA chess
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO chess;
|
||||
\else
|
||||
CREATE SCHEMA chess AUTHORIZATION chess;
|
||||
\endif
|
||||
|
||||
-- Resolve unqualified names in the chess schema without a DSN parameter.
|
||||
ALTER ROLE chess IN DATABASE chess SET search_path = chess;
|
||||
-- Bound runaway statements, lock waits, and abandoned transactions. The
|
||||
-- server's own per-operation deadlines are shorter.
|
||||
ALTER ROLE chess IN DATABASE chess SET statement_timeout = '30s';
|
||||
ALTER ROLE chess IN DATABASE chess SET lock_timeout = '10s';
|
||||
ALTER ROLE chess IN DATABASE chess SET idle_in_transaction_session_timeout = '60s';
|
||||
+34
-14
@@ -26,6 +26,10 @@ Creates new user account and returns JWT token.
|
||||
- `email` (string, optional): Valid email address
|
||||
- `password` (string, required): Minimum 8 characters, must contain letter and number
|
||||
|
||||
Accounts created here are identical to CLI-created accounts and do not
|
||||
expire. Returns 409 when the username or email is taken, and 503 when the
|
||||
registration limit (`-max-users`) or password-hashing capacity is exhausted.
|
||||
|
||||
**Response (201):**
|
||||
```json
|
||||
{
|
||||
@@ -101,7 +105,7 @@ Returns server and storage status.
|
||||
```
|
||||
|
||||
Storage states:
|
||||
- `"disabled"` - No storage path configured
|
||||
- `"disabled"` - No database configured (`-dsn` or `CHESS_DSN`)
|
||||
- `"ok"` - Database operational with auth enabled
|
||||
- `"degraded"` - A persistence write failed or the write queue filled; live games continue in memory, but durable history is no longer complete
|
||||
|
||||
@@ -177,7 +181,9 @@ Response includes all game data. Compare `moves` array length to detect changes.
|
||||
`GET /games/{gameId}/history`
|
||||
|
||||
Returns the persisted replay line even after the live game has been unloaded
|
||||
from memory or the server has restarted. History is public to anyone who knows
|
||||
from memory or the server has restarted. Games without a registered player
|
||||
are deleted 24 hours after their last activity; their history then returns
|
||||
404. History is public to anyone who knows
|
||||
the game ID, matching the existing public live-game read model. Persistent
|
||||
storage must be enabled.
|
||||
|
||||
@@ -193,7 +199,7 @@ client can replay the game without running a chess engine.
|
||||
"startTimeUtc": "2026-09-07T12:00:00Z",
|
||||
"endTimeUtc": "2026-09-07T12:15:00Z",
|
||||
"players": {
|
||||
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id"},
|
||||
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"},
|
||||
"black": {"id": "player-id", "color": 2, "type": 1}
|
||||
},
|
||||
"moves": [
|
||||
@@ -209,10 +215,12 @@ client can replay the game without running a chess engine.
|
||||
```
|
||||
|
||||
`result` is omitted while a game is ongoing. Persisted terminal values are
|
||||
`white_wins`, `black_wins`, `draw`, and `stalemate`.
|
||||
`white_wins`, `black_wins`, `draw`, and `stalemate`. A claimed player's `name`
|
||||
is their username when the claim was recorded; it survives later renames and
|
||||
account deletion, and is omitted for anonymous and computer players.
|
||||
|
||||
Returns 404 when the game has no durable record and 503 when persistence is
|
||||
disabled.
|
||||
Returns 400 for a non-canonical game ID, 404 when the game has no durable
|
||||
record, and 503 when persistence is disabled or degraded.
|
||||
|
||||
### List My Stored Games
|
||||
`GET /users/me/games?limit=50&offset=0`
|
||||
@@ -224,8 +232,11 @@ persistent storage.
|
||||
- `limit`: 1-100; defaults to 50
|
||||
- `offset`: 0-1,000,000; defaults to 0
|
||||
|
||||
Each item contains game ID, initial FEN, result/timestamps, players, and move
|
||||
count. `nextOffset` is present only when another page exists.
|
||||
Each item contains game ID, initial FEN, final FEN (after the last stored move,
|
||||
or the initial FEN when there is none), result/timestamps, players, and move
|
||||
count. `nextOffset` is present only when another page exists. A game belongs to
|
||||
a user who created it while authenticated or claimed a slot with a first move;
|
||||
the association survives later player reconfiguration.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
@@ -234,12 +245,13 @@ count. `nextOffset` is present only when another page exists.
|
||||
{
|
||||
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||
"finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
|
||||
"result": "white_wins",
|
||||
"startTimeUtc": "2026-09-07T12:00:00Z",
|
||||
"endTimeUtc": "2026-09-07T12:15:00Z",
|
||||
"moveCount": 41,
|
||||
"moveCount": 7,
|
||||
"players": {
|
||||
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id"},
|
||||
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"},
|
||||
"black": {"id": "player-id", "color": 2, "type": 1}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +326,12 @@ Error codes:
|
||||
- Registration: 5 requests/minute/IP
|
||||
- Login: 10 requests/minute/IP
|
||||
|
||||
Exceeding limit returns 429 status.
|
||||
Exceeding limit returns 429 status. Behind a reverse proxy the client IP is the
|
||||
proxy's `X-Real-IP` header, trusted only from addresses listed in
|
||||
`-trusted-proxies`.
|
||||
|
||||
Password hashing is bounded to four concurrent operations. When all are busy
|
||||
for five seconds, registration and login return 503 with `RESOURCE_LIMIT`.
|
||||
|
||||
## JWT Token Format
|
||||
|
||||
@@ -323,6 +340,9 @@ Tokens are HS256-signed JWTs valid for 7 days. Include in Authorization header:
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Token claims include `sub` (user ID), `username`, `email`, `session_id`, and
|
||||
`exp` (expiration). Authentication requires the session to exist, be unexpired,
|
||||
and belong to the JWT subject.
|
||||
The scheme is case-insensitive and the token must use RFC 6750 syntax; a
|
||||
malformed header returns 401 even on endpoints where authentication is
|
||||
optional. Claims are `iss` (`chess-server`), `aud` (`chess-api`), `sub` (user
|
||||
ID), `iat`, `nbf`, `exp`, and `extra.session_id`. Tokens carry no profile data;
|
||||
use `/auth/me`. Authentication requires the session to exist, be unexpired, and
|
||||
belong to the JWT subject. Logging in again replaces the previous session.
|
||||
+117
-79
@@ -12,33 +12,47 @@ Central command handler containing business logic. Single `Execute(Command)` ent
|
||||
In-memory live-game storage with authentication support. A mutex protects each
|
||||
game transition and callers receive immutable game views rather than mutable
|
||||
game pointers. The service manages game lifecycle, snapshots, player
|
||||
configuration, user accounts, JWT tokens, persistence, and terminal-game
|
||||
eviction. Eviction removes only the memory copy; durable replay data remains.
|
||||
configuration, user accounts, JWT tokens, persistence, and cleanup. Finished
|
||||
games leave memory after an hour while their durable rows remain; games with
|
||||
no registered player leave memory and the database 24 hours after their last
|
||||
activity (see Retention below).
|
||||
|
||||
#### Long-Polling Registry (`internal/service/waiter.go`)
|
||||
Manages clients waiting for game state changes via HTTP long-polling. Tracks move counts per client, sends notifications on state changes, enforces 30-second timeout. Non-blocking notification pattern handles slow clients gracefully. Coordinates with service layer for game updates and deletion events.
|
||||
|
||||
#### Authentication Module (`internal/service/user.go`, `internal/http/auth.go`)
|
||||
- **Password Hashing**: Argon2id for secure password storage
|
||||
- **JWT Management**: HS256 tokens with 7-day expiration
|
||||
- **Password Hashing**: Argon2id (`lixenwraith/auth`), at most four concurrent
|
||||
derivations; unknown accounts verify against a dummy hash for equal timing
|
||||
- **JWT Management**: one scoped `auth.JWT` manager (HS256, issuer
|
||||
`chess-server`, audience `chess-api`, 7-day lifetime, zero leeway); the key
|
||||
comes from `-jwt-secret-file`, a fixed dev key, or a per-process random key
|
||||
- **Bearer Parsing**: `auth.ParseBearerToken` (RFC 6750 syntax)
|
||||
- **User Operations**: Registration, login, profile management
|
||||
- **Session Tracking**: One persisted session per user, with JWT subject/session
|
||||
binding and last-login timestamps
|
||||
|
||||
### Storage Layer (`internal/server/storage`)
|
||||
SQLite persistence with ordered asynchronous writes for gameplay and
|
||||
synchronous writes for authentication. A bounded channel (1,000 operations)
|
||||
feeds one transactional writer. Move persistence groups the move, first-move
|
||||
slot claim, and terminal result in one transaction. Replay reads insert a
|
||||
barrier behind accepted writes and then read the game and moves in one SQLite
|
||||
PostgreSQL 18 persistence through pgx v5 (`database/sql` via `pgx/stdlib`),
|
||||
with ordered asynchronous writes for gameplay and synchronous writes for
|
||||
accounts. A bounded channel (1,000 operations) feeds one transactional writer,
|
||||
which preserves per-game ordering (a move never overtakes its game's insert or
|
||||
a rewind). Move persistence groups the move, first-move slot claim, and
|
||||
terminal result in one transaction. Replay reads insert a barrier behind
|
||||
accepted writes and then read the game and moves in one REPEATABLE READ
|
||||
snapshot.
|
||||
|
||||
WAL, foreign-key enforcement, a five-second busy timeout, and NORMAL
|
||||
synchronous mode are configured in the connection string so every pooled
|
||||
connection receives the same settings. The pool is deliberately small (eight
|
||||
open, four idle) because SQLite has one writer. A write failure or full queue
|
||||
marks storage degraded; live play remains available in memory and `/health`
|
||||
reports that durable history may be incomplete.
|
||||
The writer retries a transaction that failed before COMMIT with a transient
|
||||
error (lost connection, administrator shutdown, serialization failure,
|
||||
deadlock) with doubling backoff for about three seconds, so a PostgreSQL
|
||||
restart does not degrade storage. A COMMIT error (unknown outcome), a permanent
|
||||
error, or a full queue marks storage degraded; live play remains available in
|
||||
memory and `/health` reports that durable history may be incomplete.
|
||||
|
||||
Synchronous operations carry a five-second deadline. The pool holds at most ten
|
||||
connections (five idle). The schema is resolved through `search_path`, so the
|
||||
role default (`chess`) or a DSN parameter selects it. `InitDB` applies ordered,
|
||||
transactional migrations under an advisory lock and issues no DDL when the
|
||||
schema is current, which lets a DML-only runtime role start the server.
|
||||
|
||||
### Supporting Modules
|
||||
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
|
||||
@@ -53,9 +67,9 @@ reports that durable history may be incomplete.
|
||||
### User Registration
|
||||
1. HTTP handler receives `POST /auth/register` with credentials
|
||||
2. Validates username format and password strength
|
||||
3. Service layer hashes password with Argon2id
|
||||
4. Creates user record with unique ID (collision detection)
|
||||
5. Generates JWT token
|
||||
3. Service layer hashes password with Argon2id (bounded concurrency)
|
||||
4. Creates the user and its initial session in one transaction (random UUIDv4 ID)
|
||||
5. Generates a scoped JWT bound to that session
|
||||
6. Returns token and user information
|
||||
|
||||
### Authenticated Game Creation
|
||||
@@ -74,7 +88,7 @@ reports that durable history may be incomplete.
|
||||
5. If legal, gets new FEN from engine
|
||||
6. Calls `service.ApplyMoveWithState()` with the FEN, turn, and state that were validated
|
||||
7. Service rejects a stale concurrent commit or atomically updates the move, optional slot claim, and terminal result
|
||||
8. The same logical mutation is queued as one SQLite transaction
|
||||
8. The same logical mutation is queued as one database transaction
|
||||
9. Returns GameResponse
|
||||
|
||||
### Computer Move
|
||||
@@ -100,19 +114,22 @@ reports that durable history may be incomplete.
|
||||
1. Client requests `GET /api/v1/games/{id}/history`
|
||||
2. Storage queues a barrier after all previously accepted gameplay writes
|
||||
3. The writer reaches the barrier only after those transactions finish
|
||||
4. Storage reads the game row and ordered moves in one read transaction
|
||||
4. Storage reads the game row and ordered moves in one REPEATABLE READ transaction
|
||||
5. The API returns the initial FEN plus every UCI move and resulting FEN
|
||||
6. This path works after terminal-memory eviction or a server restart
|
||||
|
||||
## Persistence Flow
|
||||
|
||||
### User Write Operations (Synchronous)
|
||||
1. Service serializes public registrations within the process
|
||||
2. Storage checks uniqueness and capacity in a transaction
|
||||
3. At capacity, the oldest temporary user is evicted in that transaction
|
||||
4. The new user and initial session commit together; any failure rolls back the entire operation
|
||||
5. Login replaces the user's single session with one SQLite UPSERT
|
||||
6. Other account mutations commit before success is returned
|
||||
1. The password is hashed outside any lock, within the KDF concurrency bound
|
||||
2. Storage takes a transaction-scoped advisory lock, so account creation is
|
||||
serialized across the server and CLI processes
|
||||
3. Uniqueness is checked first, so a duplicate is reported even when registration is full
|
||||
4. Public registration is refused once `-max-users` accounts exist; nothing is evicted
|
||||
5. The new user and initial session commit together; any failure rolls back the entire operation
|
||||
6. Login replaces the user's single session and records `last_login_at` in one statement
|
||||
7. Other account mutations commit before success is returned; unique violations map to "already exists"
|
||||
8. Registered and CLI-created accounts are identical and never expire
|
||||
|
||||
### Game Write Operations (Asynchronous)
|
||||
1. Service layer calls a storage method (`RecordNewGame`, `RecordMove`, `RecordPlayers`, or `RewindGame`)
|
||||
@@ -124,10 +141,14 @@ reports that durable history may be incomplete.
|
||||
|
||||
### Query Operations
|
||||
1. Replay-sensitive game reads wait on the async-write barrier
|
||||
2. User-game filtering matches creation-time player IDs and later claim IDs
|
||||
3. Move lookup uses the `UNIQUE(game_id, move_number)` index prefix and returns move order directly
|
||||
4. Username and email lookups are case-insensitive
|
||||
5. CLI database queries format records as tabular output
|
||||
2. "My games" matches claims (`white_claimed_by`/`black_claimed_by`) through two
|
||||
composite partial indexes; authenticated creators and first movers always
|
||||
record a claim, and claims survive player reconfiguration
|
||||
3. The listing reads each game's move count and final FEN from its last move
|
||||
with a LATERAL probe of the `moves` primary key, not a per-row count
|
||||
4. Move history is read in primary-key order `(game_id, move_number)`
|
||||
5. Usernames and emails are stored lowercase; lookups lower their input
|
||||
6. Malformed IDs are treated as "not found" before reaching the database
|
||||
|
||||
## Concurrency
|
||||
|
||||
@@ -137,7 +158,8 @@ reports that durable history may be incomplete.
|
||||
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
|
||||
- **Validation Engine**: Single mutex-protected instance for synchronous validation
|
||||
- **Storage Writer**: Single goroutine processes game write queue sequentially
|
||||
- **User Operations**: Direct database access with transaction isolation
|
||||
- **User Operations**: Direct database access; account creation serialized by a PostgreSQL advisory lock
|
||||
- **Password Hashing**: Four Argon2id slots; waiters give up after five seconds (HTTP 503)
|
||||
- **PID Lock**: File-based exclusive lock prevents multiple instances
|
||||
|
||||
## Data Structures
|
||||
@@ -165,15 +187,18 @@ type Snapshot struct {
|
||||
```
|
||||
|
||||
### JWT Claims
|
||||
```go
|
||||
```json
|
||||
{
|
||||
"iss": "chess-server",
|
||||
"aud": ["chess-api"],
|
||||
"sub": "user-id",
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"session_id": "session-id",
|
||||
"exp": 1234567890
|
||||
"iat": 1234567890,
|
||||
"nbf": 1234567890,
|
||||
"exp": 1235172690,
|
||||
"extra": {"session_id": "session-id"}
|
||||
}
|
||||
```
|
||||
Profile data is not placed in the token; clients read it from `/auth/me`.
|
||||
|
||||
### Command Pattern with User Context
|
||||
Commands encapsulate operations with type, arguments, and optional user ID for authenticated requests.
|
||||
@@ -181,67 +206,79 @@ Commands encapsulate operations with type, arguments, and optional user ID for a
|
||||
### Player Configuration
|
||||
Players identified by UUID (authenticated users) or generated IDs (anonymous), configured with type (human/computer), skill level, and search time.
|
||||
|
||||
### Storage Schema
|
||||
### Storage Schema (version 1)
|
||||
```sql
|
||||
-- User authentication table
|
||||
users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||
email TEXT COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME
|
||||
user_id uuid PRIMARY KEY,
|
||||
username text NOT NULL UNIQUE, -- lowercase, 1-64 characters
|
||||
email text UNIQUE, -- lowercase or NULL
|
||||
password_hash text NOT NULL, -- Argon2id PHC string
|
||||
created_at timestamptz NOT NULL,
|
||||
last_login_at timestamptz
|
||||
)
|
||||
|
||||
sessions (
|
||||
session_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL UNIQUE REFERENCES users ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL,
|
||||
expires_at timestamptz NOT NULL
|
||||
)
|
||||
|
||||
-- Game storage with player associations
|
||||
games (
|
||||
game_id TEXT PRIMARY KEY,
|
||||
initial_fen TEXT,
|
||||
white_player_id TEXT, -- User ID or generated UUID
|
||||
white_type INTEGER,
|
||||
white_level INTEGER,
|
||||
white_search_time INTEGER,
|
||||
black_player_id TEXT, -- User ID or generated UUID
|
||||
black_type INTEGER,
|
||||
black_level INTEGER,
|
||||
black_search_time INTEGER,
|
||||
start_time_utc DATETIME,
|
||||
result TEXT, -- white_wins, black_wins, draw, or stalemate
|
||||
end_time_utc DATETIME,
|
||||
white_claimed_by TEXT, -- user that claimed the slot after creation
|
||||
black_claimed_by TEXT
|
||||
game_id uuid PRIMARY KEY,
|
||||
initial_fen text NOT NULL,
|
||||
white_player_id uuid NOT NULL, -- user ID or generated UUID
|
||||
white_type smallint NOT NULL, -- 1 human, 2 computer
|
||||
white_level smallint NOT NULL, -- 0-20
|
||||
white_search_time integer NOT NULL,
|
||||
white_claimed_by uuid, -- user that owns the slot
|
||||
white_name text, -- claimant's username at claim time
|
||||
black_... (same columns),
|
||||
start_time_utc timestamptz NOT NULL,
|
||||
result text, -- white_wins, black_wins, draw, stalemate
|
||||
end_time_utc timestamptz, -- set exactly when result is set
|
||||
)
|
||||
|
||||
-- Move history
|
||||
moves (
|
||||
move_id INTEGER PRIMARY KEY,
|
||||
game_id TEXT,
|
||||
move_number INTEGER,
|
||||
move_uci TEXT,
|
||||
fen_after_move TEXT,
|
||||
player_color TEXT,
|
||||
move_time_utc DATETIME,
|
||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
UNIQUE (game_id, move_number)
|
||||
game_id uuid REFERENCES games ON DELETE CASCADE,
|
||||
move_number integer, -- 1..n without gaps
|
||||
move_uci text NOT NULL, -- [a-h][1-8][a-h][1-8][qrbn]?
|
||||
fen_after_move text NOT NULL,
|
||||
player_color text NOT NULL, -- 'w' | 'b'
|
||||
move_time_utc timestamptz NOT NULL,
|
||||
PRIMARY KEY (game_id, move_number)
|
||||
)
|
||||
```
|
||||
|
||||
Schema version 2 is applied idempotently to legacy databases with guarded
|
||||
`ALTER TABLE ADD COLUMN` migrations. Redundant indexes formerly duplicating
|
||||
UNIQUE constraints or useful index prefixes are removed. Purpose-built partial
|
||||
indexes cover non-empty email uniqueness, temporary-user cleanup, session
|
||||
expiry, and post-creation game claims.
|
||||
`schema_version` records the applied version. Indexes cover session expiry,
|
||||
each claim column ordered by `(start_time_utc DESC, game_id DESC)` for the game
|
||||
listing, and the start time of unclaimed games for the anonymous-game purge.
|
||||
Claims and player IDs deliberately have no foreign key to `users`: history
|
||||
outlives deleted accounts, and the name snapshot keeps it readable.
|
||||
|
||||
### Retention
|
||||
- Sessions are deleted after expiry (7 days after the last login).
|
||||
- Finished games leave memory after `-finished-game-ttl` (1 hour); their rows stay.
|
||||
- Games with no claimed slot are unloaded from memory and deleted from the
|
||||
database 24 hours after their last activity (`-anonymous-game-ttl`). The
|
||||
hourly cleanup evicts idle games first, then queues the delete through the
|
||||
ordered writer while excluding every game still loaded, so no live game can
|
||||
lose its row. See [`deploy/postgresql`](../deploy/postgresql) for
|
||||
provisioning and the one-time SQLite import.
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Authentication Flow
|
||||
1. Password validation enforces minimum complexity
|
||||
2. Argon2id hashing prevents rainbow table attacks
|
||||
3. JWT tokens expire after 7 days
|
||||
4. Case-insensitive username/email matching prevents enumeration
|
||||
5. Constant-time password verification prevents timing attacks
|
||||
3. JWT tokens expire after 7 days and must name this service as issuer/audience
|
||||
4. Case-insensitive username/email matching prevents duplicate accounts
|
||||
5. Unknown accounts and wrong passwords cost the same Argon2id work and
|
||||
return the same error
|
||||
|
||||
### Rate Limiting Strategy
|
||||
- Client IP: the `-proxy-header` value (default `X-Real-IP`) only on connections
|
||||
from `-trusted-proxies`; otherwise the TCP peer
|
||||
- General API: 10 req/s per IP (20 in dev mode)
|
||||
- Registration: 5 req/min per IP (prevent spam accounts)
|
||||
- Login: 10 req/min per IP (prevent brute force)
|
||||
@@ -249,7 +286,8 @@ expiry, and post-creation game claims.
|
||||
|
||||
### Data Protection
|
||||
- Passwords never stored in plaintext
|
||||
- JWT secret rotates on restart (or fixed in dev mode)
|
||||
- JWT key from a 0600 key file, or rotated on restart (fixed in dev mode)
|
||||
- Database access by Unix-socket peer authentication; no stored DB password
|
||||
- User IDs use UUIDs with collision detection
|
||||
- Transactions keep registration, sessions, moves, claims, results, and rewinds internally consistent
|
||||
- Case-insensitive queries prevent duplicate accounts
|
||||
@@ -0,0 +1,286 @@
|
||||
# Deployment: FreeBSD Jail with PostgreSQL 18
|
||||
|
||||
Production layout: host nginx terminates TLS and reverse-proxies `/chess/` to
|
||||
`chess-server` in a Bastille jail. PostgreSQL 18 runs in the same jail and
|
||||
listens **only on its Unix socket**. The `chess` OS account has no shell or
|
||||
password; its database role reaches only the `chess` database through peer
|
||||
authentication, so no database password exists.
|
||||
|
||||
```
|
||||
internet ──TLS──▶ host nginx ──HTTP──▶ jail: chess-server (API port)
|
||||
│ Unix socket, peer auth
|
||||
▼
|
||||
jail: PostgreSQL 18 (no TCP listener)
|
||||
```
|
||||
|
||||
`host#` runs on the Bastille host as root; `jail#` runs inside the jail as root
|
||||
(`bastille console <jail>`).
|
||||
|
||||
## Scripted Deployment
|
||||
|
||||
The scripts in [`deploy/`](../deploy) perform every step below and are safe to
|
||||
rerun for upgrades. Their header comments list all inputs.
|
||||
|
||||
1. **Host:** give the jail its own System V shared-memory namespace, which
|
||||
PostgreSQL needs, and restart it:
|
||||
|
||||
```sh
|
||||
host# sh deploy/freebsd/host.sh chess
|
||||
```
|
||||
|
||||
2. **Build** the static server on any machine with Go 1.26+, then copy the
|
||||
binary and a checkout of this repository into the jail:
|
||||
|
||||
```sh
|
||||
make server-freebsd # bin/chess-server-freebsd-amd64
|
||||
```
|
||||
|
||||
3. **Jail:** install and configure everything. `OLD_SERVICE` names the rc.d
|
||||
service of the SQLite deployment; `SQLITE_DB` imports its data once.
|
||||
|
||||
```sh
|
||||
jail# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \
|
||||
API_HOST=10.17.89.10 TRUSTED_PROXIES=10.17.89.1 \
|
||||
OLD_SERVICE=chess SQLITE_DB=/usr/local/chess/db/chess.db \
|
||||
sh deploy/freebsd/setup-jail.sh
|
||||
```
|
||||
|
||||
It ends with a health check that requires `"storage":"ok"`.
|
||||
|
||||
4. **Host:** add the location block from
|
||||
[`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf) to
|
||||
the TLS server block, then `nginx -t && service nginx reload`.
|
||||
|
||||
5. **Verify** with the checklist at the end of this document. Keep the old
|
||||
SQLite file until you are satisfied; the import does not modify it.
|
||||
|
||||
`setup-jail.sh` inputs:
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `CHESS_BINARY` | required | New `chess-server` binary |
|
||||
| `API_HOST` | required | Listen address (the jail IP) |
|
||||
| `API_PORT` | `8080` | Listen port |
|
||||
| `TRUSTED_PROXIES` | empty (warns) | nginx address(es) as seen from the jail |
|
||||
| `CHESS_HOME` | account home, else `/usr/local/chess` | Binary and JWT key location |
|
||||
| `LOG_DIR` | `/var/log/chess` | Server log directory |
|
||||
| `SQLITE_DB` | none | v0.11 SQLite database to import into an empty schema |
|
||||
| `OLD_SERVICE` | none | Previous rc.d service to stop and disable |
|
||||
| `SPLIT_PRIVILEGES` | `no` | `yes` for a DML-only runtime role (below) |
|
||||
| `EXTRA_FLAGS` | none | More server flags, e.g. `-max-users 500` |
|
||||
| `PGDATA` | `/var/db/postgres/data18` | PostgreSQL data directory |
|
||||
|
||||
What the script changes, in order:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| Packages | `postgresql18-server`, `postgresql18-client`, `stockfish` (and `sqlite3` for an import) |
|
||||
| PostgreSQL | `initdb` with UTF-8, builtin `C.UTF-8` locale, peer local auth, host auth rejected; data checksums are on by default in 18 |
|
||||
| `pg_hba.conf` | Only `postgres` (all databases) and `chess` (database `chess`), both `local` + `peer`; original kept as `pg_hba.conf.orig` |
|
||||
| `postgresql.auto.conf` | `listen_addresses = ''` (no TCP), socket in `/tmp`, `scram-sha-256` |
|
||||
| Provisioning | [`deploy/postgresql/setup.sql`](../deploy/postgresql/setup.sql) on first run only |
|
||||
| Account | `chess` created if missing (`nologin`, no password); a warning if an existing account has a login shell |
|
||||
| Files | Binary `root:wheel 0555` (previous copy kept as `.prev`); `jwt.key` generated once, `chess:chess 0600`; log directory `chess:chess 0750` |
|
||||
| Schema | `chess-server db init` (as `chess`, or as `chess_owner` in split mode) |
|
||||
| Import | [`migrate-sqlite.sh`](../deploy/postgresql/migrate-sqlite.sh) when `SQLITE_DB` is set and the schema is empty |
|
||||
| Service | [`rc.d/chess_server`](../deploy/freebsd/rc.d/chess_server) and `rc.conf` via `sysrc` |
|
||||
| Log rotation | `/usr/local/etc/newsyslog.conf.d/chess_server.conf`: daily or 1 MB, 7 kept |
|
||||
| Backups | `/usr/local/sbin/chess-backup` via `/usr/local/etc/cron.d/chess-backup`: `pg_dump` at 03:30 as `postgres`, 14 days kept |
|
||||
|
||||
The script was exercised on Linux with FreeBSD-only commands (`pkg`, `sysrc`,
|
||||
`service`, `pw`, `fetch`) stubbed and everything else real: PostgreSQL 18.6,
|
||||
`su`, provisioning in both privilege modes, schema migration, the SQLite
|
||||
import, and the health check of the running server.
|
||||
|
||||
## Accounts and Data Retention
|
||||
|
||||
- Accounts created by registration on the site and by `chess-server db user
|
||||
add` are identical and never expire. Public registration closes at
|
||||
`-max-users` accounts (default 100; `0` removes the limit); the CLI is not
|
||||
limited.
|
||||
- A game belongs to a registered user when they created it while signed in or
|
||||
claimed a slot with their first move. Such games are kept indefinitely and
|
||||
record the player's username at claim time.
|
||||
- Games with no registered player are **deleted 24 hours after their last
|
||||
activity** (creation, move, undo, reconfiguration, or result), from memory and
|
||||
from the database, by the hourly cleanup. `-anonymous-game-ttl` changes the
|
||||
window; `0` keeps them.
|
||||
- Deleting a user removes their sessions; their games keep the claim and name.
|
||||
|
||||
## Reference
|
||||
|
||||
The sections below describe what the scripts configure, for review or manual
|
||||
installation.
|
||||
|
||||
### Jail prerequisites
|
||||
|
||||
PostgreSQL allocates a small System V shared-memory segment even though its
|
||||
main buffers use `mmap`. `host.sh` runs:
|
||||
|
||||
```sh
|
||||
host# bastille config chess set sysvshm new
|
||||
host# bastille restart chess
|
||||
```
|
||||
|
||||
`sysvshm=new` gives the jail an isolated namespace, unlike the older shared
|
||||
`allow.sysvipc`. Without it, `initdb` fails with `could not create shared
|
||||
memory segment`.
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```sh
|
||||
jail# pkg install postgresql18-server postgresql18-client stockfish
|
||||
jail# sysrc postgresql_enable=YES
|
||||
jail# sysrc postgresql_initdb_flags="--encoding=UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8 --auth-local=peer --auth-host=reject"
|
||||
jail# service postgresql initdb
|
||||
jail# service postgresql start
|
||||
jail# su -m postgres -c "psql -X -d postgres" <<'SQL'
|
||||
ALTER SYSTEM SET listen_addresses = '';
|
||||
ALTER SYSTEM SET unix_socket_directories = '/tmp';
|
||||
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
|
||||
SQL
|
||||
jail# service postgresql restart
|
||||
```
|
||||
|
||||
`/var/db/postgres/data18/pg_hba.conf`:
|
||||
|
||||
```
|
||||
# TYPE DATABASE USER METHOD
|
||||
local all postgres peer
|
||||
local chess chess peer
|
||||
```
|
||||
|
||||
### Provisioning and privilege models
|
||||
|
||||
[`setup.sql`](../deploy/postgresql/setup.sql) creates the login role `chess`
|
||||
(no superuser, create-db, create-role, replication, or RLS bypass; 20
|
||||
connections), the `chess` database (`template0`, builtin `C.UTF-8`), and the
|
||||
`chess` schema. It revokes `PUBLIC` access to the database and to schema
|
||||
`public`, and sets role defaults for `search_path`, `statement_timeout`,
|
||||
`lock_timeout`, and `idle_in_transaction_session_timeout`.
|
||||
|
||||
```sh
|
||||
jail# su -m postgres -c 'psql -X -d postgres -f -' < deploy/postgresql/setup.sql
|
||||
```
|
||||
|
||||
| Mode | Schema owner | `chess` role can | Schema migrations |
|
||||
|---|---|---|---|
|
||||
| Owner (default) | `chess` | DML and DDL in schema `chess` only | Automatic at server start |
|
||||
| Split (`-v split=true`, `SPLIT_PRIVILEGES=yes`) | `chess_owner` (NOLOGIN) | `SELECT/INSERT/UPDATE/DELETE` only | `setup-jail.sh`, or manually as `postgres` (below) |
|
||||
|
||||
```sh
|
||||
jail# su -m postgres -c "/usr/local/chess/chess-server db init \
|
||||
-dsn \"dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'\""
|
||||
```
|
||||
|
||||
In split mode the server refuses to start while a migration is pending.
|
||||
|
||||
### The `chess` account
|
||||
|
||||
| Resource | Requirement |
|
||||
|---|---|
|
||||
| Shell/password | `/usr/sbin/nologin`, password disabled |
|
||||
| Home | Holds `chess-server` (`root:wheel 0555`) and `jwt.key` |
|
||||
| JWT key | `chess:chess 0600`, at least 32 bytes; the server refuses group- or world-readable keys |
|
||||
| Log directory | `chess:chess 0750` |
|
||||
| Database | Role `chess` over `/tmp/.s.PGSQL.5432`, database `chess` only |
|
||||
| Network | Listens on the API port (unprivileged, above 1024) |
|
||||
| Engine | `stockfish` in `PATH` (`/usr/local/bin`; set through `chess_server_env`) |
|
||||
|
||||
Administrative CLI commands run as `chess` so peer authentication selects the
|
||||
right role; root may use `su -m` although the account has no shell:
|
||||
|
||||
```sh
|
||||
jail# su -m chess -c '/usr/local/chess/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
|
||||
```
|
||||
|
||||
### JWT signing key
|
||||
|
||||
`setup-jail.sh` generates the key once with `openssl rand -base64 48`. Without
|
||||
a key file, production mode generates a new key at every start and every
|
||||
browser session ends on restart. To rotate, replace the file and restart the
|
||||
service; users then sign in again.
|
||||
|
||||
### Service
|
||||
|
||||
`rc.conf` as written by `setup-jail.sh`:
|
||||
|
||||
```sh
|
||||
chess_server_enable="YES"
|
||||
chess_server_account="chess"
|
||||
chess_server_binary="/usr/local/chess/chess-server"
|
||||
chess_server_logfile="/var/log/chess/chess-server.log"
|
||||
chess_server_env="PATH=/usr/local/bin:/usr/bin:/bin"
|
||||
chess_server_flags="-api-host 10.17.89.10 -api-port 8080 -dsn postgres:///chess?host=/tmp -jwt-secret-file /usr/local/chess/jwt.key -log-level info -trusted-proxies 10.17.89.1"
|
||||
```
|
||||
|
||||
The rc.d script starts `daemon(8)` as root, which writes the supervisor
|
||||
pidfile, drops to `chess`, restarts the server if it exits, and reopens the log
|
||||
on SIGHUP for newsyslog. It declares `REQUIRE: postgresql`. The SQLite-era
|
||||
`-storage-path` flag no longer exists; a server started with it exits with
|
||||
`flag provided but not defined`.
|
||||
|
||||
| Flag | Environment default | Purpose |
|
||||
|---|---|---|
|
||||
| `-dsn` | `CHESS_DSN` | PostgreSQL connection string; empty disables persistence |
|
||||
| `-jwt-secret-file` | `CHESS_JWT_SECRET_FILE` | Stable JWT signing key |
|
||||
| `-trusted-proxies` | | nginx address(es) as seen from the jail |
|
||||
| `-proxy-header` | | Client-IP header set by nginx (default `X-Real-IP`) |
|
||||
| `-max-users` | | Registration cap (default 100, `0` = none) |
|
||||
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
|
||||
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
|
||||
|
||||
### Host nginx
|
||||
|
||||
[`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf) passes the client
|
||||
address in `X-Real-IP` (which nginx overwrites), allows 30-second long-polls,
|
||||
and bounds request bodies. The server keys rate limits on `X-Real-IP` only for
|
||||
connections from `-trusted-proxies`; any other peer is keyed by its own
|
||||
address. Without `-trusted-proxies`, every proxied request shares one bucket.
|
||||
The existing security headers (HSTS, `X-Frame-Options`, CSP with
|
||||
`connect-src 'self'`) are unaffected: the API is same-origin under `/chess/`,
|
||||
and this release does not change the web client.
|
||||
|
||||
### SQLite import
|
||||
|
||||
[`migrate-sqlite.sh`](../deploy/postgresql/migrate-sqlite.sh) reads a
|
||||
schema-version-2 SQLite database (v0.11) and imports, in one transaction, all
|
||||
users as regular accounts with their Argon2id hashes, unexpired sessions,
|
||||
games with claims and player names, results, and moves. It refuses a schema
|
||||
that already has data. Imported games without a registered player follow the
|
||||
24-hour retention. Sessions carry over, but tokens signed with the old
|
||||
per-restart key do not, so users sign in once. Manual invocation:
|
||||
|
||||
```sh
|
||||
jail# su -m postgres -c 'sh /path/to/migrate-sqlite.sh /tmp/legacy.db dbname=chess'
|
||||
```
|
||||
|
||||
### Upgrades, backups, and restores
|
||||
|
||||
- **Upgrade:** build the new binary and rerun `setup-jail.sh` with the same
|
||||
inputs (omit `SQLITE_DB`). It keeps the previous binary as
|
||||
`chess-server.prev`, migrates the schema, and restarts the service. To roll
|
||||
back a release without a schema change, copy `.prev` back and restart.
|
||||
- **Migrations** are transactional and serialized by an advisory lock. A
|
||||
server refuses to start against a schema newer than it supports.
|
||||
- **Backups:** `chess-backup` writes `pg_dump --format=custom` files to
|
||||
`/var/db/postgres/backups` nightly and removes those older than 14 days. Copy
|
||||
them off the jail.
|
||||
- **Restore** into a freshly provisioned database:
|
||||
`su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'`
|
||||
(`--role=chess_owner` in split mode).
|
||||
- **PostgreSQL restarts:** the server retries in-flight writes across a brief
|
||||
outage. A write that still fails marks storage `degraded` in `/health`; live
|
||||
games continue in memory, but durable history stops until the server is
|
||||
restarted.
|
||||
|
||||
### Verification checklist
|
||||
|
||||
```sh
|
||||
jail# su -m chess -c 'psql -X -d chess -Atc "SHOW search_path"' # chess
|
||||
jail# su -m chess -c 'psql -X -d postgres -c "select 1"' # rejected by pg_hba
|
||||
jail# sockstat -4 -6 -l | grep postgres # no TCP listener
|
||||
jail# service chess_server status
|
||||
jail# fetch -qo - http://10.17.89.10:8080/health # "storage":"ok"
|
||||
host# curl -s https://lixen.com/chess/health # through nginx
|
||||
```
|
||||
+85
-49
@@ -4,7 +4,7 @@
|
||||
|
||||
- Go 1.26+
|
||||
- Stockfish in PATH
|
||||
- SQLite3
|
||||
- PostgreSQL 18 (server for persistence; `psql` for the shell test suites)
|
||||
- Git
|
||||
- curl, jq (for testing)
|
||||
|
||||
@@ -26,12 +26,17 @@ go build ./cmd/chess-client-cli
|
||||
- `-web-host`: Web UI server host (default: localhost)
|
||||
- `-web-port`: Web UI server port (default: 9090)
|
||||
- `-dev`: Development mode with relaxed rate limits and fixed JWT secret
|
||||
- `-storage-path`: SQLite database file path (enables persistence and authentication)
|
||||
- `-dsn`: PostgreSQL connection string (default `$CHESS_DSN`); enables persistence and authentication
|
||||
- `-jwt-secret-file`: Stable JWT signing key file, mode 0600, at least 32 bytes (default `$CHESS_JWT_SECRET_FILE`)
|
||||
- `-trusted-proxies`: Comma-separated reverse-proxy IPs/CIDRs whose `-proxy-header` is trusted
|
||||
- `-proxy-header`: Client-IP header from a trusted proxy (default `X-Real-IP`)
|
||||
- `-pid`: PID file path for process tracking
|
||||
- `-pid-lock`: Enable exclusive locking (requires -pid)
|
||||
- `-log-level`: `debug`, `info`, `warn`, or `error` (default: `info`)
|
||||
- `-log-http`: Enable API and web request logs (default: `true`)
|
||||
- `-finished-game-ttl`: How long terminal games stay in memory (default: `1h`; `0` disables eviction)
|
||||
- `-anonymous-game-ttl`: How long games without a registered player survive after their last activity, in memory and in the database (default: `24h`; `0` keeps them)
|
||||
- `-max-users`: Accounts at which public registration closes (default: `100`; `0` = no limit; the CLI is not limited)
|
||||
- `-web-api-url`: Browser-visible API origin for the embedded web client; useful when its public origin differs from the listen address
|
||||
|
||||
### Modes
|
||||
@@ -40,84 +45,107 @@ go build ./cmd/chess-client-cli
|
||||
./chess-server
|
||||
|
||||
# With persistence and authentication
|
||||
./chess-server -storage-path ./db/chess.db
|
||||
./chess-server -dsn 'dbname=chess'
|
||||
|
||||
# Development with all features
|
||||
./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -serve
|
||||
./chess-server -dev -dsn 'dbname=chess' -pid /tmp/chess-server.pid -serve
|
||||
|
||||
# Detailed persistence, engine-queue, cleanup, and request logs
|
||||
./chess-server -dev -storage-path chess.db -serve -log-level debug -log-http=true
|
||||
./chess-server -dev -dsn 'dbname=chess' -serve -log-level debug -log-http=true
|
||||
|
||||
# Behind a reverse proxy that sets X-Real-IP
|
||||
./chess-server -dsn 'dbname=chess' -trusted-proxies 10.0.0.1
|
||||
|
||||
# Web UI is public at one origin while the API is exposed at another
|
||||
./chess-server -serve -web-api-url https://api.example.test
|
||||
|
||||
# Initialize database with user tables
|
||||
./chess-server db init -path chess.db
|
||||
# Create or migrate the schema
|
||||
./chess-server db init -dsn 'dbname=chess'
|
||||
```
|
||||
|
||||
### Local PostgreSQL
|
||||
|
||||
Any PostgreSQL 18 works for development. With a local server and peer
|
||||
authentication for your OS user:
|
||||
|
||||
```bash
|
||||
sudo -u postgres createuser "$USER"
|
||||
sudo -u postgres createdb -O "$USER" chess
|
||||
export CHESS_DSN='dbname=chess'
|
||||
```
|
||||
|
||||
Production provisioning (roles, schema, `pg_hba.conf`) is in
|
||||
[deployment.md](./deployment.md).
|
||||
|
||||
## Database Management
|
||||
|
||||
All `db` subcommands take `-dsn`, defaulting to `$CHESS_DSN`; the examples
|
||||
below assume it is exported. Commands other than `init` and `delete` refuse to
|
||||
run against a missing or outdated schema.
|
||||
|
||||
### Schema Initialization
|
||||
```bash
|
||||
# Create all tables (users, games, moves)
|
||||
./chess-server db init -path chess.db
|
||||
# Create or migrate all tables; safe to repeat
|
||||
./chess-server db init
|
||||
```
|
||||
|
||||
### User Management CLI
|
||||
```bash
|
||||
# Add user with password
|
||||
./chess-server db user add -path chess.db -username alice -password SecurePass123
|
||||
./chess-server db user add -username alice -password SecurePass123
|
||||
|
||||
# Add user with email
|
||||
./chess-server db user add -path chess.db -username bob -email bob@example.com -password BobPass456
|
||||
./chess-server db user add -username bob -email bob@example.com -password BobPass456
|
||||
|
||||
# Interactive password input
|
||||
./chess-server db user add -path chess.db -username charlie -interactive
|
||||
./chess-server db user add -username charlie -interactive
|
||||
|
||||
# List all users
|
||||
./chess-server db user list -path chess.db
|
||||
./chess-server db user list
|
||||
|
||||
# Update password
|
||||
./chess-server db user set-password -path chess.db -username alice -password NewPass789
|
||||
./chess-server db user set-password -username alice -password NewPass789
|
||||
|
||||
# Update email
|
||||
./chess-server db user set-email -path chess.db -username alice -email newemail@example.com
|
||||
./chess-server db user set-email -username alice -email newemail@example.com
|
||||
|
||||
# Update username
|
||||
./chess-server db user set-username -path chess.db -current alice -new alice2
|
||||
./chess-server db user set-username -current alice -new alice2
|
||||
|
||||
# Import with existing Argon2 hash
|
||||
./chess-server db user set-hash -path chess.db -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...'
|
||||
./chess-server db user set-hash -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...'
|
||||
|
||||
# Delete user
|
||||
./chess-server db user delete -path chess.db -username alice
|
||||
./chess-server db user delete -username alice
|
||||
```
|
||||
|
||||
### Game Query CLI
|
||||
```bash
|
||||
# Query all games
|
||||
./chess-server db query -path chess.db -gameId "*"
|
||||
./chess-server db query -gameId "*"
|
||||
|
||||
# Query games for specific user
|
||||
./chess-server db query -path chess.db -playerId "550e8400-e29b-41d4-a716-446655440000"
|
||||
./chess-server db query -playerId "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
# Query specific game
|
||||
./chess-server db query -path chess.db -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef"
|
||||
./chess-server db query -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef"
|
||||
|
||||
# Delete database (destructive)
|
||||
./chess-server db delete -path chess.db
|
||||
# Drop every chess table in the DSN's search_path (destructive)
|
||||
./chess-server db delete -confirm
|
||||
```
|
||||
|
||||
## Authentication Configuration
|
||||
|
||||
### JWT Secret Management
|
||||
- **Production**: A cryptographically secure 32-byte secret is generated on
|
||||
startup. This intentionally invalidates JWTs after a restart even though the
|
||||
SQLite session rows remain; configuring a stable deployment secret is tracked
|
||||
in `doc/todo.md`.
|
||||
- **Development** (`-dev`): Fixed secret for testing consistency
|
||||
- **Sessions**: Stored for 7 days and renewed on each login; effective token
|
||||
lifetime is also bounded by signing-key rotation
|
||||
- **Production**: Provide `-jwt-secret-file` so tokens survive restarts. The
|
||||
file must be a regular file with no group/other permissions and hold at
|
||||
least 32 bytes (`openssl rand -base64 48`). Without it, a random key is
|
||||
generated per process and every token is invalidated on restart, although
|
||||
the session rows remain.
|
||||
- **Development** (`-dev`): Fixed secret for testing consistency when no key file is given
|
||||
- **Scope**: Tokens are issued for `chess-server` / `chess-api`, carry only the
|
||||
subject and session ID, and are accepted only while their session row exists
|
||||
- **Sessions**: Stored for 7 days and renewed on each login
|
||||
|
||||
### Password Requirements
|
||||
- Minimum 8 characters
|
||||
@@ -160,7 +188,7 @@ chess/
|
||||
│ │ ├── service.go # Core service
|
||||
│ │ ├── game.go # Game operations
|
||||
│ │ └── user.go # User and auth operations
|
||||
│ └── storage/ # SQLite persistence
|
||||
│ └── storage/ # PostgreSQL persistence (pgtest: per-test schemas)
|
||||
│ ├── storage.go # Async writer for games
|
||||
│ ├── game.go # Game persistence
|
||||
│ ├── user.go # User persistence (synchronous)
|
||||
@@ -180,13 +208,15 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
|
||||
# User authentication and database tests
|
||||
./test/test-db.sh
|
||||
|
||||
# Run test server with sample users
|
||||
./test/test-db-server.sh
|
||||
# Run test server with sample users (needs a disposable CHESS_TEST_DSN)
|
||||
./test/run-test-server.sh
|
||||
|
||||
# Test real-time game updates via long-polling
|
||||
./test/test-longpoll.sh
|
||||
|
||||
# Unit, migration, and persistence tests
|
||||
# Unit, migration, and persistence tests; database tests are skipped unless
|
||||
# CHESS_TEST_DSN names a database the test role may create schemas in
|
||||
export CHESS_TEST_DSN='postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable'
|
||||
go test ./...
|
||||
|
||||
# Concurrency checks for the state/persistence boundary
|
||||
@@ -201,8 +231,11 @@ go test -race ./internal/server/storage ./internal/server/service
|
||||
- Queue capacity: 100 (internal/processor/queue.go)
|
||||
- Min search time: 100ms (internal/processor/processor.go)
|
||||
- Write queue: 1000 operations (internal/server/storage/storage.go)
|
||||
- DB connections: 8 max, 4 idle (internal/server/storage/storage.go)
|
||||
- DB connections: 10 max, 5 idle (internal/server/storage/storage.go)
|
||||
- DB operation deadline: 5 seconds; write transactions 10 seconds
|
||||
- Transient write retries: 6 attempts, 100 ms doubling backoff
|
||||
- JWT expiration: 7 days (internal/service/user.go)
|
||||
- Concurrent Argon2id derivations: 4, five-second wait (internal/service/user.go)
|
||||
- Long-poll timeout: 30 seconds (internal/server/service/waiter.go)
|
||||
- Long-poll channel buffer: 1 (internal/service/waiter.go)
|
||||
|
||||
@@ -213,20 +246,21 @@ go test -race ./internal/server/storage ./internal/server/service
|
||||
- Hash algorithm: Argon2id (memory-hard, side-channel resistant)
|
||||
|
||||
### Storage Configuration
|
||||
- WAL mode and NORMAL synchronous mode enabled on every connection
|
||||
- Foreign key constraints and a five-second busy timeout enabled on every connection
|
||||
- PostgreSQL via pgx; schema chosen by `search_path`; migrations run at startup
|
||||
- Async write pattern for games; shutdown drains every accepted write
|
||||
- Replay reads wait for prior queued writes and use one read transaction
|
||||
- Transient failures before COMMIT are retried; others degrade storage
|
||||
- Replay reads wait for prior queued writes and use one REPEATABLE READ transaction
|
||||
- Synchronous writes for user operations (data consistency)
|
||||
- Registration capacity/eviction, user creation, and initial session are atomic
|
||||
- A full queue or write failure degrades to memory-only and is visible in logs and `/health`
|
||||
- Case-insensitive collation for usernames and emails
|
||||
- Registration limit check, user creation, and initial session are atomic
|
||||
- Games without a registered player are deleted 24 hours after their last activity
|
||||
- A full queue or unrecovered write failure degrades to memory-only and is visible in logs and `/health`
|
||||
- Usernames and emails stored lowercase with unique constraints
|
||||
|
||||
### Rate Limiting Configuration
|
||||
- General endpoints: 10 req/s (20 in dev mode)
|
||||
- User registration: 5 req/min
|
||||
- User login: 10 req/min
|
||||
- Rate limit key: IP address from X-Forwarded-For or connection
|
||||
- Rate limit key: `-proxy-header` (default `X-Real-IP`) from `-trusted-proxies`, otherwise the TCP peer
|
||||
|
||||
### PID Management
|
||||
- Singleton enforcement requires same PID file path
|
||||
@@ -247,10 +281,11 @@ go test -race ./internal/server/storage ./internal/server/service
|
||||
|
||||
### Authentication Security
|
||||
- Passwords hashed with Argon2id before storage
|
||||
- JWT tokens signed with HS256
|
||||
- Constant-time password comparison
|
||||
- Case-insensitive matching prevents user enumeration
|
||||
- JWT tokens signed with HS256 and scoped by issuer/audience
|
||||
- Constant-time password comparison; unknown accounts verify a dummy hash
|
||||
- Case-insensitive matching prevents duplicate accounts
|
||||
- Rate limiting on auth endpoints prevents brute force
|
||||
- Argon2id concurrency is bounded to cap memory under load
|
||||
|
||||
### Input Validation
|
||||
- All user inputs validated and sanitized
|
||||
@@ -261,18 +296,19 @@ go test -race ./internal/server/storage ./internal/server/service
|
||||
### Session Management
|
||||
- JWT tokens expire after 7 days
|
||||
- No token refresh mechanism (re-login required)
|
||||
- Tokens include minimal claims (user ID, username, email)
|
||||
- Secret rotates on server restart (except dev mode)
|
||||
- Tokens carry only the user ID and session ID
|
||||
- Key is stable with `-jwt-secret-file`; otherwise it rotates on restart (fixed in dev mode)
|
||||
|
||||
## Limitations
|
||||
|
||||
- JWT tokens don't support refresh (must re-login after expiry)
|
||||
- User deletion doesn't cascade to games (games remain with player IDs)
|
||||
- User deletion doesn't cascade to games (games keep player and claim IDs)
|
||||
- Games played without a registered player are deleted 24 hours after their last activity
|
||||
- No password recovery mechanism
|
||||
- No email verification for registration
|
||||
- Fixed worker pool size for engine calculations
|
||||
- No push-based game updates (30-second long-polling is used)
|
||||
- Live games are not rehydrated after restart; persisted games are currently replay-only
|
||||
- Database history has no automatic retention policy
|
||||
- Curated-game metadata and replay controls are deferred to [Replay Implementation Tasks](./todo.md)
|
||||
- Replay UI, PGN export, and curated games are planned in [Replay Plan](./todo.md)
|
||||
- REST API only
|
||||
+133
-240
@@ -1,266 +1,159 @@
|
||||
# Replay Implementation Tasks
|
||||
# Game Replay Plan
|
||||
|
||||
This plan covers the remaining work needed for first-class replay in the web
|
||||
and CLI clients, durable player-game browsing, and a curated archive of famous
|
||||
games. The persistence/API foundation completed by the database audit is listed
|
||||
first so later work does not duplicate or bypass it.
|
||||
Target experience: a user registers, signs in, plays, and later opens **My
|
||||
games** to pick any finished or unfinished game. Replay runs automatically or
|
||||
one ply at a time for either side, moves backward and forward freely, and
|
||||
exports the FEN or PGN of the position at the current ply, in the spirit of the
|
||||
chess.com analysis viewer.
|
||||
|
||||
## Foundation Available Now
|
||||
This plan starts from the PostgreSQL release and splits the remaining work
|
||||
into phases that each ship and test on their own.
|
||||
|
||||
- [x] Store terminal `result` and `end_time_utc` on each game.
|
||||
- [x] Persist `white_claimed_by` and `black_claimed_by`, including claims made
|
||||
on the first valid move after game creation.
|
||||
- [x] Commit each move, first-move claim, and move-caused terminal result in one
|
||||
SQLite transaction.
|
||||
- [x] Rewind moves and clear a stale terminal result/end time in one transaction.
|
||||
- [x] Return ordered UCI moves with `fenAfterMove` through
|
||||
`GET /api/v1/games/{gameId}/history`.
|
||||
- [x] Return bounded pages through authenticated
|
||||
`GET /api/v1/users/me/games?limit=&offset=`.
|
||||
- [x] Provide matching Go client DTOs and methods (`GetGameHistory`,
|
||||
`GetMyGames`) without prematurely adding CLI presentation.
|
||||
- [x] Evict terminal games from memory after a configurable TTL while retaining
|
||||
durable rows and moves.
|
||||
- [x] Add an async-write barrier and a single SQLite read snapshot for immediate,
|
||||
internally consistent replay reads.
|
||||
- [x] Configure browser API origin through `/config`, with `/chess` fallback for
|
||||
the static deployment at `lixen.com/projects/chess/`.
|
||||
- [x] Add debug-level persistence, cleanup, engine queue, and lifecycle logging.
|
||||
## Foundation in Place
|
||||
|
||||
## Decisions Required Before Replay UI Work
|
||||
- [x] PostgreSQL 18 schema with durable games, ordered moves, results, end
|
||||
times, and slot claims; transactional migrations (`schema_version`).
|
||||
- [x] Each accepted move, first-move claim, and move-caused result commits in
|
||||
one transaction; undo removes moves and clears the result atomically.
|
||||
- [x] `GET /api/v1/games/{id}/history`: initial FEN plus UCI and resulting FEN
|
||||
for every ply, read in one REPEATABLE READ snapshot after pending writes.
|
||||
- [x] `GET /api/v1/users/me/games`: claimed games, newest first, with move
|
||||
count and final FEN (thumbnail) from a primary-key probe per game.
|
||||
- [x] Claims survive player reconfiguration and terminal-game eviction; the
|
||||
listing indexes are ordered for keyset pagination.
|
||||
- [x] Stable JWT key (`-jwt-secret-file`), so a returning user's session
|
||||
survives server restarts.
|
||||
- [x] Uniform, non-expiring accounts for site and CLI users (D1); 24-hour
|
||||
retention of games without a registered player (D1).
|
||||
- [x] Player-name snapshots on claim, returned in history and listings (D4).
|
||||
- [x] Scripted FreeBSD jail deployment (`deploy/`).
|
||||
- [x] Go client DTOs (`GetGameHistory`, `GetMyGames`) including `finalFen`.
|
||||
|
||||
| Decision | Current behavior | Decision needed |
|
||||
## Decisions
|
||||
|
||||
| # | Decision | Status |
|
||||
|---|---|---|
|
||||
| History visibility | Public to anyone with a game UUID, like live game reads | Keep public, make games private by default, or add per-game visibility |
|
||||
| Live mutation authorization | Configure, undo, computer-trigger, and unload remain UUID-based; claimed slots protect human moves only | Define owner/opponent/spectator permissions before replay and archive UI expose more game discovery |
|
||||
| Database retention | Indefinite; only terminal in-memory state is evicted | Retention by account type, archive status, age, or explicit deletion |
|
||||
| Delete semantics | `DELETE /games/{id}` unloads memory but retains history | Name it “close/unload,” or add a separate authorized durable delete |
|
||||
| Durability guarantee | Gameplay continues after a write failure; health becomes degraded | Keep best-effort, acknowledge writes, retry with an outbox, or fail gameplay closed |
|
||||
| Result model | `white_wins`, `black_wins`, `draw`, `stalemate` | Keep compatibility or split outcome (`1-0`, `0-1`, `1/2-1/2`) from termination reason |
|
||||
| Archived-game owner | Not created | Protected system/demo user, separate archive owner table, or ownerless source records |
|
||||
| Replay notation | UCI plus authoritative FEN after each move | Add SAN and canonical PGN at import/write time or derive them on read |
|
||||
| D1 | Account lifetime | **Done.** Site registrations and CLI-created accounts are identical and never expire; public registration closes at `-max-users`. Games without a registered player are deleted 24 hours after their last activity (`-anonymous-game-ttl`). |
|
||||
| D2 | History visibility | **Decided: shareable by game ID.** The history endpoint stays public to holders of the 122-bit random ID; IDs are never listed publicly and the list endpoint stays authenticated. |
|
||||
| D3 | Where SAN/PGN is produced | **Decided: server-side Go** (R1), derived on read from the initial FEN and the UCI line: no schema change, one implementation for web and CLI, no third-party script under the CSP. |
|
||||
| D4 | Player names | **Done.** `white_name`/`black_name` snapshot the claimant's username in the claim transaction and survive renames and deletions; exposed as `players.*.name`. |
|
||||
| D5 | Draw rules | Open. Recommendation: detect insufficient material, threefold repetition, and the 50/75-move rules in the R1 core; add resign/draw offers later. PGN `Result` is `*` for unterminated games. |
|
||||
| D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. |
|
||||
| D7 | List pagination | Open. Recommendation: keyset cursor on `(start_time_utc, game_id)`, keeping offset during v1; the claim indexes are already ordered for it. |
|
||||
|
||||
Record these choices in an ADR before changing the v1 response contract. Until
|
||||
privacy is decided, do not add searchable public player-game indexes or expose
|
||||
usernames in public history.
|
||||
## Phase R1 — Notation Core (server, no API change)
|
||||
|
||||
## Phase 1 — Complete the Durable Game Model
|
||||
A small, dependency-free chess core in Go (`internal/server/chess`), used by
|
||||
replay only; Stockfish remains the move validator for live play.
|
||||
|
||||
### Results and termination
|
||||
- [ ] Board model with FEN parse/serialize (extend `internal/server/board`).
|
||||
- [ ] Legal move generation: pseudo-legal moves plus king-safety filtering,
|
||||
castling rights and paths, en passant, promotion.
|
||||
- [ ] SAN encode (disambiguation by file, rank, or both; `+`/`#`; `O-O`,
|
||||
`O-O-O`; `=Q`) and SAN decode for later PGN import.
|
||||
- [ ] PGN writer: Seven Tag Roster (White/Black from the name snapshots, else
|
||||
"Anonymous" or "Stockfish level N"), `SetUp`/`FEN` tags for custom starts,
|
||||
`Termination`, 80-column movetext, `Result` from the stored outcome.
|
||||
- [ ] Draw-rule helpers (D5) and an integrity check that replays the stored UCI
|
||||
line and compares each generated FEN with the stored `fenAfterMove`.
|
||||
|
||||
- [ ] Represent outcome separately from termination reason. Candidate fields:
|
||||
`outcome`, `termination`, and optional `result_detail`.
|
||||
- [ ] Detect and persist all supported draw paths, not only stalemate:
|
||||
insufficient material, repetition, fifty/seventy-five-move rule, and agreed
|
||||
draw if that interaction is added.
|
||||
- [ ] Define behavior for resignation, timeout, abandonment, engine failure,
|
||||
and administrative termination.
|
||||
- [ ] Add constraints covering valid combinations: an end time requires a
|
||||
terminal outcome; an ongoing game has neither.
|
||||
- [ ] Decide whether undoing a finished rated/player game is allowed. If yes,
|
||||
preserve an audit event rather than silently rewriting official history.
|
||||
Test gate:
|
||||
|
||||
### Stable participant metadata
|
||||
- [ ] Perft node counts for the standard CPW positions (start, Kiwipete, and
|
||||
positions 3–6) to depth 4, which exercises castling, en passant, promotion,
|
||||
and pins.
|
||||
- [ ] Property tests over random legal games: SAN decode(encode(m)) == m; the
|
||||
generated FEN sequence is identical to one produced by Stockfish's `d`
|
||||
command for the same line.
|
||||
- [ ] Fuzz targets for FEN parsing, SAN decoding, and PGN writing (no panics,
|
||||
bounded output).
|
||||
- [ ] Integrity check run over every stored game in a production-shaped copy.
|
||||
|
||||
- [ ] Snapshot display names at game start/end so replay remains readable after
|
||||
a user rename or temporary-account deletion.
|
||||
- [ ] Separate historical participant identity from mutable controller config.
|
||||
Changing a human slot to a computer must never remove the user's game link.
|
||||
- [ ] Decide whether anonymous players receive a durable pseudonym, remain
|
||||
unnamed, or are excluded from archive browsing.
|
||||
- [ ] Add optional clocks/time-control metadata before timeout results are
|
||||
supported.
|
||||
## Phase R2 — Replay API
|
||||
|
||||
### Notation and integrity
|
||||
- [ ] Implement D7 (keyset cursor) once decided.
|
||||
- [ ] Additive history fields: `san` per move, `outcome`/`termination`, and
|
||||
`pgnResult`; keep existing fields unchanged.
|
||||
- [ ] `GET /api/v1/games/{id}/pgn?ply=N`: `application/x-chess-pgn`,
|
||||
`Content-Disposition: attachment; filename="chess-<date>-<id8>.pgn"`,
|
||||
moves 1..N (default all). The FEN at any ply is already in the history.
|
||||
- [ ] `ETag` and `Cache-Control: private, max-age` for terminal games;
|
||||
`If-None-Match` returns 304.
|
||||
- [ ] Filters on the list endpoint: result, color, ongoing/finished.
|
||||
- [ ] Tests: handler tests against PostgreSQL (pgtest), golden PGN files for
|
||||
mate, stalemate, promotion, castling, en passant, custom FEN, and an ongoing
|
||||
game; shell-suite coverage for authorization (another user's list, expired
|
||||
session) and cursor stability under concurrent inserts.
|
||||
|
||||
- [ ] Add SAN per ply and canonical PGN, or add a deterministic backend
|
||||
converter from the stored initial FEN/UCI line.
|
||||
- [ ] Validate that `move_number`, `player_color`, FEN side-to-move, and the
|
||||
previous position form one legal continuous line.
|
||||
- [ ] Add a stored content hash for import idempotency and corruption checks.
|
||||
- [ ] Add a repair/audit CLI command that reports broken game rows without
|
||||
mutating them; make repair an explicit separate operation.
|
||||
- [ ] Define a schema-migration policy beyond v2, including forward-version
|
||||
rejection, backup instructions, and rollback limitations.
|
||||
## Phase R3 — Web Replay UI
|
||||
|
||||
## Phase 2 — Replay and Library APIs
|
||||
Plain JavaScript in the embedded client, no framework.
|
||||
|
||||
### Player games
|
||||
- [ ] **My games** button beside the account indicator (authenticated only)
|
||||
opening a panel: mini-board from `finalFen`, result, date, opponent or
|
||||
engine level, move count; loading, empty, error, and "load more" states.
|
||||
- [ ] Replay mode entered from the panel or a deep link
|
||||
`?replay=<gameId>&ply=<n>`; `history.pushState` so browser back/forward
|
||||
restores the list and the ply.
|
||||
- [ ] Controls: first, previous, next, last; keyboard `←` `→` `Home` `End`;
|
||||
clickable SAN move list; autoplay with speed selector and pause; board flip;
|
||||
current ply announced through an `aria-live` region.
|
||||
- [ ] Position rendering assigns the stored FEN for the ply; the browser never
|
||||
computes moves.
|
||||
- [ ] Export at the current ply: copy FEN, copy PGN, and download PGN (fetched
|
||||
from R2 as a `Blob`, saved through a temporary `<a download>`).
|
||||
- [ ] While replaying: stop live long-polling, disable move, undo, new-game,
|
||||
and player-configuration controls; **Back to live game** restores them.
|
||||
|
||||
- [ ] Add filters to the authenticated list: `status`, `result`, color, opponent
|
||||
type, and date range.
|
||||
- [ ] Replace offset pagination with a stable `(start_time_utc, game_id)` cursor
|
||||
before the table grows large; retain v1 offset parameters during migration.
|
||||
- [ ] Return a compact display label/opponent summary so clients do not recreate
|
||||
association logic.
|
||||
- [ ] Define whether an authenticated user may list a game merely created for
|
||||
their random player ID versus one explicitly claimed by them.
|
||||
- [ ] Add authorization tests for expired/deleted sessions and attempts to list
|
||||
another user's games.
|
||||
Constraints from the host's security headers:
|
||||
|
||||
### Replay payload
|
||||
- `script-src 'self' 'wasm-unsafe-eval'`: all code in `app.js`; no inline
|
||||
scripts, `on*` attributes, `eval`, or `new Function`.
|
||||
- `style-src 'self' 'unsafe-inline'`: styles belong in `style.css`; state
|
||||
classes instead of inline style strings.
|
||||
- `img-src 'self' data:`: pieces remain Unicode glyphs or `data:` SVG.
|
||||
- `connect-src 'self'`: all requests go to the same origin under `/chess/`
|
||||
(the existing `/config` fallback); no CDN or cross-origin API.
|
||||
- `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN`: the iframe host
|
||||
page must stay on the same origin. Downloads from `blob:` URLs and
|
||||
`navigator.clipboard` work in a same-origin frame.
|
||||
|
||||
- [ ] Version the history payload before adding annotations, evaluations,
|
||||
comments, variations, clocks, or PGN tags.
|
||||
- [ ] Include a canonical final FEN and normalized outcome/termination fields.
|
||||
- [ ] Decide whether long games return one payload or paged/chunked moves.
|
||||
- [ ] Add `ETag`/`If-None-Match` for immutable finished histories.
|
||||
- [ ] Add a downloadable PGN response with correct `Content-Type` and filename.
|
||||
- [ ] Return an explicit “ongoing/incomplete” marker when history is requested
|
||||
before a terminal result.
|
||||
Test gate:
|
||||
|
||||
### Live-game restoration
|
||||
- [ ] Playwright (Chromium) end-to-end against server and PostgreSQL: play a
|
||||
short game, open My games, step backward and forward, autoplay to the end,
|
||||
and compare exported FEN/PGN with the API.
|
||||
- [ ] Keyboard-only navigation and screen-reader labels; reduced-motion
|
||||
disables autoplay animation.
|
||||
- [ ] Deep link works beneath `/projects/chess/` with the API at `/chess`.
|
||||
|
||||
- [ ] Decide whether a server restart should make unfinished games playable or
|
||||
replay-only.
|
||||
- [ ] If play must resume, load the last persisted FEN, next turn, player config,
|
||||
claims, and move list into memory at startup.
|
||||
- [ ] Mark games interrupted in `pending` state as recoverable `stuck` or
|
||||
`ongoing`; never re-submit an engine task blindly.
|
||||
- [ ] Define reconciliation when the service previously entered degraded mode
|
||||
and memory contains moves absent from SQLite.
|
||||
## Phase R4 — CLI and WASM Replay (optional)
|
||||
|
||||
## Phase 3 — Curated Famous-Game Archive
|
||||
- [ ] `games` (list with pagination), `replay <gameId|index>`, `next`, `prev`,
|
||||
`first`, `last`, `goto <ply>`, `auto <ms>`, `fen`, `pgn save <path>`.
|
||||
- [ ] Replay state is separate from the live session: no polling, moves, undo,
|
||||
or configuration while replaying.
|
||||
|
||||
### Schema and ownership
|
||||
## Phase R5 — Curated Archive (later)
|
||||
|
||||
- [ ] Add a game origin such as `player`, `curated`, or `imported`.
|
||||
- [ ] Add searchable archive metadata: title, event, site, event date, round,
|
||||
white/black display names, Elo values, ECO/opening, source URL, source license,
|
||||
attribution text, and import timestamp.
|
||||
- [ ] Add publication state, featured flag, and explicit featured rank/order.
|
||||
- [ ] Create a protected demo/system identity only if ownership remains tied to
|
||||
users. It must not consume temporary-user capacity, expire, authenticate, or
|
||||
be evicted/deleted through normal user tools.
|
||||
- [ ] Prefer a separate protected archive owner over credentials embedded in
|
||||
seed scripts.
|
||||
- [ ] Add only indexes backed by actual archive queries; confirm each with
|
||||
`EXPLAIN QUERY PLAN` and a representative data volume.
|
||||
- [ ] Schema: game origin (`player`, `curated`), archive metadata (event, site,
|
||||
date, round, names, Elo, ECO, source URL, license, attribution), publication
|
||||
state, featured order; no user ownership.
|
||||
- [ ] `chess-server db archive import <pgn|dir>` using the R1 SAN decoder:
|
||||
validate every move, import each game in one transaction, idempotent by
|
||||
content hash, dry-run and per-file summary.
|
||||
- [ ] Public, bounded, cacheable list endpoint; replay through the same
|
||||
history/PGN endpoints.
|
||||
- [ ] Verify redistribution rights for every bundled collection.
|
||||
|
||||
### Import pipeline
|
||||
## Operations Carried Forward
|
||||
|
||||
- [ ] Add `chess-server db archive import` for one PGN or a directory.
|
||||
- [ ] Parse PGN tags, comments, NAGs, and variations deliberately; document
|
||||
which are preserved and which are discarded in the first version.
|
||||
- [ ] Validate every main-line move from its initial position and generate the
|
||||
authoritative FEN sequence before opening the transaction.
|
||||
- [ ] Import a game and all moves in one transaction.
|
||||
- [ ] Make repeated imports idempotent by source key/content hash.
|
||||
- [ ] Add dry-run, structured error output, per-file summary, and all-or-nothing
|
||||
versus continue-on-error modes.
|
||||
- [ ] Preserve source attribution and verify redistribution rights for every
|
||||
bundled collection.
|
||||
- [ ] Seed a small, reviewed fixture set in tests; keep large archives outside
|
||||
the executable and repository unless licensing and binary size are accepted.
|
||||
|
||||
### Archive API
|
||||
|
||||
- [ ] Add a public, bounded curated list endpoint with stable sorting.
|
||||
- [ ] Add exact filters required by the UI (featured, player name, event, year,
|
||||
ECO); do not expose an unconstrained database query API.
|
||||
- [ ] Reuse the same history representation for player and curated games.
|
||||
- [ ] Cache immutable curated list/history responses and invalidate only on
|
||||
archive administration.
|
||||
|
||||
## Phase 4 — CLI Replay Experience
|
||||
|
||||
- [ ] Add `games`/`games mine` to call `GetMyGames`, show pagination, result,
|
||||
colors, opponent/controller, date, and move count.
|
||||
- [ ] Add `games featured` after the curated endpoint exists.
|
||||
- [ ] Add `replay <gameId>` and allow selection from a prior list result.
|
||||
- [ ] Render the initial FEN before ply 1; never assume the standard start.
|
||||
- [ ] Add next/previous/start/end navigation, move-number jump, and optional
|
||||
autoplay speed.
|
||||
- [ ] Display UCI initially and SAN once the backend contract supplies it.
|
||||
- [ ] Clearly separate replay state from live session state: replay commands
|
||||
must not poll, move, undo, configure, or delete the live game.
|
||||
- [ ] Add `pgn save <path>` after the PGN endpoint is defined.
|
||||
- [ ] Cover empty lists, ongoing histories, custom FEN, malformed/incomplete
|
||||
history, expired auth, server restart, and deleted live-memory state.
|
||||
|
||||
## Phase 5 — Web Replay Experience
|
||||
|
||||
- [ ] Add “My games” for authenticated users and a separate “Classic games”
|
||||
collection available without login.
|
||||
- [ ] Build accessible loading, empty, pagination, and error states.
|
||||
- [ ] Add a replay route/deep link, for example `?replay=<gameId>`, that works
|
||||
beneath `/projects/chess/` and does not assume the API shares that path.
|
||||
- [ ] Initialize from `initialFen`; step by assigning the stored
|
||||
`fenAfterMove`, not by replaying moves through a browser chess engine.
|
||||
- [ ] Add previous/next/start/end buttons, move-list selection, keyboard
|
||||
controls, autoplay speed, pause, and current-ply announcement.
|
||||
- [ ] Disable move, computer-trigger, undo, and player-configuration actions in
|
||||
replay mode.
|
||||
- [ ] Stop live long-polling when replay mode begins and restore it only when a
|
||||
live game is explicitly reopened.
|
||||
- [ ] Show result, termination, players, date/event, source attribution, and
|
||||
custom-start notice.
|
||||
- [ ] Make browser back/forward restore list filters and replay ply.
|
||||
- [ ] Test both embedded `/config` and the deployed `/chess` fallback, including
|
||||
CORS and reverse-proxy headers.
|
||||
- [ ] Add responsive and accessibility checks for board orientation, focus,
|
||||
screen-reader labels, reduced motion, and high contrast.
|
||||
|
||||
## Phase 6 — Durability, Operations, and Scale
|
||||
|
||||
- [ ] Choose and implement the durability contract from the decision table.
|
||||
For acknowledged persistence, return success only after a writer receipt or
|
||||
use a durable outbox with retries and ordering.
|
||||
- [ ] Expose counters/metrics for queue depth, enqueue rejection, write latency,
|
||||
failed transaction, barrier latency, replay read latency, and terminal-memory
|
||||
eviction.
|
||||
- [ ] Add request/game correlation fields to logs without logging JWTs,
|
||||
passwords, or full private payloads.
|
||||
- [ ] Configure a stable production JWT signing key (prefer a secret file or
|
||||
deployment secret) so persisted sessions can survive a server restart;
|
||||
document rotation and invalidation procedures.
|
||||
- [ ] Add a bounded degraded-mode recovery procedure; current behavior requires
|
||||
operator intervention/restart and cannot reconstruct missing writes.
|
||||
- [ ] Benchmark list and history queries with realistic user/archive sizes and
|
||||
verify query plans in CI.
|
||||
- [ ] Set WAL checkpoint and database backup procedures; test online backup and
|
||||
restore with active reads/writes.
|
||||
- [ ] Define database retention separately for anonymous, temporary-user,
|
||||
permanent-user, and curated games.
|
||||
- [ ] Add authorized durable deletion/anonymization if required by the privacy
|
||||
policy, with archive records protected from accidental cascades.
|
||||
|
||||
## Required Test Matrix
|
||||
|
||||
- [ ] Upgrade a production-shaped legacy database to every new schema version
|
||||
and reopen it with foreign keys enabled on multiple pooled connections.
|
||||
- [ ] Read history immediately after create, move, terminal move, slot claim,
|
||||
player reconfiguration, and undo—without sleeps.
|
||||
- [ ] Run concurrent legal moves from one position; exactly one may commit and
|
||||
the loser must receive `GAME_CONFLICT`.
|
||||
- [ ] Submit duplicate computer triggers; only one engine task may run.
|
||||
- [ ] Fill the write queue/fault SQLite and assert degraded health, visible
|
||||
logging, and documented client behavior.
|
||||
- [ ] Shut down with queued writes and prove all accepted writes drain.
|
||||
- [ ] Restart after a finished game and replay the exact FEN sequence/result.
|
||||
- [ ] Evict a terminal game from memory and replay it from SQLite.
|
||||
- [ ] Change a claimed human slot to computer and verify “My games” association
|
||||
remains.
|
||||
- [ ] Verify registration duplicate/session failures roll back account creation
|
||||
and capacity eviction.
|
||||
- [ ] Exercise public/private history rules for anonymous, owner, opponent, and
|
||||
unrelated authenticated clients.
|
||||
- [ ] Validate imported PGNs with promotions, castling, en passant, custom FEN,
|
||||
comments, and every supported result.
|
||||
- [ ] Run Go unit/race tests, HTTP integration scripts, JavaScript syntax/tests,
|
||||
and browser end-to-end replay navigation in CI.
|
||||
|
||||
## Replay Definition of Done
|
||||
|
||||
- A finished player game survives restart, appears once in its owner's list,
|
||||
and replays deterministically from the stored initial FEN to the stored final
|
||||
FEN in both clients.
|
||||
- A curated game is imported idempotently with source attribution, appears in a
|
||||
stable public collection, and uses the same replay path as a player game.
|
||||
- Undo, player reconfiguration, terminal eviction, and concurrent requests
|
||||
cannot produce a stale result, missing claim, duplicate ply, or mixed history
|
||||
snapshot.
|
||||
- Privacy, retention, durable deletion, and degraded-write behavior are
|
||||
documented and enforced consistently by API, storage, web, and CLI layers.
|
||||
- Query plans and benchmarks show no redundant indexes or unbounded list scans
|
||||
at the agreed deployment size.
|
||||
- [ ] Metrics: write-queue depth, retries, degraded transitions, flush and
|
||||
replay latency, KDF wait time.
|
||||
- [ ] Degraded-mode recovery without a restart (replay missing writes from
|
||||
memory or fail gameplay closed); today recovery requires a restart.
|
||||
- [ ] Live-game restoration after restart (unfinished games are replay-only).
|
||||
- [ ] Retention and user-initiated deletion or anonymization of games.
|
||||
- [ ] Thread request contexts from HTTP handlers into storage calls; today each
|
||||
call carries its own deadline.
|
||||
- [ ] Draw and resign flows (D5) for live play, independent of replay.
|
||||
@@ -1,33 +1,37 @@
|
||||
module chess
|
||||
|
||||
go 1.26.0
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/gofiber/fiber/v2 v2.52.14
|
||||
github.com/go-playground/validator/v10 v10.30.5
|
||||
github.com/gofiber/fiber/v2 v2.52.15
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8
|
||||
github.com/mattn/go-sqlite3 v1.14.48
|
||||
golang.org/x/term v0.45.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/lixenwraith/auth v0.0.0-20260927004242-3b8d4a2946f1
|
||||
golang.org/x/term v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.14 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/molecule-man/go-brrr v1.1.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.72.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
github.com/valyala/fasthttp v1.74.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
)
|
||||
@@ -1,136 +1,74 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.14 h1:8eyElddS5wbWNDG4sIupw+IX2jEjHX2aqAAq/9C3M8s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.14/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
|
||||
github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
|
||||
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE=
|
||||
github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
|
||||
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
|
||||
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
|
||||
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
|
||||
github.com/gofiber/fiber/v2 v2.52.15 h1:Cov1uKeVPyu9q0jSrN60W+A8XNX+/WK8J7cy5osHLIk=
|
||||
github.com/gofiber/fiber/v2 v2.52.15/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 h1:c7wfyZGdy6RkM/b6mIazoYrAS+3qDL7d9M1CFm2e1VA=
|
||||
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226/go.mod h1:1Kfy3ggtRbgrzR+qg99SaeUmmnUZKtur8uOSQsbWaPw=
|
||||
github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8 h1:AOj9NHdpB3mSRXHYr4iOhFypovIDtzwWMAM80o6JNEY=
|
||||
github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8/go.mod h1:anmBvIoOyZGcw/TaZPZAvzCYoFOBgVJji5MGP5MFZJE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/lixenwraith/auth v0.0.0-20260927004242-3b8d4a2946f1 h1:pXqSd6vdTuTh4lYdSmsoCpAJohZYAatZguE835Ru4NU=
|
||||
github.com/lixenwraith/auth v0.0.0-20260927004242-3b8d4a2946f1/go.mod h1:411JUqnbQgy9jgoFV+fH91TfVUEfzYA4dyPv7f4c264=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
|
||||
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
|
||||
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/molecule-man/go-brrr v1.1.1 h1:KYwusQhjtV3smALnhmafZf4OBrh7vP4p8O47DyUmmuY=
|
||||
github.com/molecule-man/go-brrr v1.1.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
||||
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
||||
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
|
||||
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU=
|
||||
github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -57,6 +57,7 @@ type PlayerInfo struct {
|
||||
Level int `json:"level,omitempty"`
|
||||
SearchTime int `json:"searchTime,omitempty"`
|
||||
ClaimedBy string `json:"claimedBy,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type MoveInfo struct {
|
||||
@@ -120,6 +121,7 @@ type HistoryMove struct {
|
||||
type GameSummary struct {
|
||||
GameID string `json:"gameId"`
|
||||
InitialFEN string `json:"initialFen"`
|
||||
FinalFEN string `json:"finalFen"`
|
||||
Result string `json:"result,omitempty"`
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
|
||||
@@ -69,10 +69,12 @@ type HistoryMove struct {
|
||||
}
|
||||
|
||||
// GameSummary is intentionally sufficient for a client-side game picker; the
|
||||
// full move list remains on the per-game history endpoint.
|
||||
// full move list remains on the per-game history endpoint. FinalFEN is the
|
||||
// position after the last stored move (the initial FEN when there is none).
|
||||
type GameSummary struct {
|
||||
GameID string `json:"gameId"`
|
||||
InitialFEN string `json:"initialFen"`
|
||||
FinalFEN string `json:"finalFen"`
|
||||
Result string `json:"result,omitempty"`
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
|
||||
@@ -19,6 +19,7 @@ type Player struct {
|
||||
Level int `json:"level,omitempty"` // Only for computer
|
||||
SearchTime int `json:"searchTime,omitempty"` // Only for computer
|
||||
ClaimedBy string `json:"claimedBy,omitempty"` // UserID that claimed this slot
|
||||
Name string `json:"name,omitempty"` // Claimant's username when the claim was stored (history only)
|
||||
}
|
||||
|
||||
// PlayerConfig for API requests and configuration
|
||||
|
||||
@@ -284,4 +284,3 @@ func (u *UCI) Close() error {
|
||||
u.killLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type Game struct {
|
||||
state core.State
|
||||
lastResult *MoveResult
|
||||
endTimeUTC *time.Time
|
||||
lastActivity time.Time // last create, move, undo, reconfiguration, or state change
|
||||
}
|
||||
|
||||
// View is an immutable copy of the state needed by processors and transports.
|
||||
@@ -133,10 +134,6 @@ func (g *Game) SetLastResult(result *MoveResult) {
|
||||
g.lastResult = ©
|
||||
}
|
||||
|
||||
func (g *Game) LastResult() *MoveResult {
|
||||
return g.lastResult
|
||||
}
|
||||
|
||||
// CurrentSnapshot returns the latest game snapshot
|
||||
func (g *Game) CurrentSnapshot() Snapshot {
|
||||
return g.snapshots[len(g.snapshots)-1]
|
||||
@@ -217,11 +214,8 @@ func (g *Game) State() core.State {
|
||||
return g.state
|
||||
}
|
||||
|
||||
func (g *Game) SetState(s core.State) {
|
||||
g.SetStateAt(s, time.Now().UTC())
|
||||
}
|
||||
|
||||
func (g *Game) SetStateAt(s core.State, at time.Time) {
|
||||
g.Touch(at)
|
||||
if s.IsTerminal() {
|
||||
if !g.state.IsTerminal() || g.endTimeUTC == nil {
|
||||
ended := at.UTC()
|
||||
@@ -233,6 +227,28 @@ func (g *Game) SetStateAt(s core.State, at time.Time) {
|
||||
g.state = s
|
||||
}
|
||||
|
||||
// Touch records activity at the given time; earlier times are ignored.
|
||||
func (g *Game) Touch(at time.Time) {
|
||||
if at.After(g.lastActivity) {
|
||||
g.lastActivity = at
|
||||
}
|
||||
}
|
||||
|
||||
// LastActivity returns the latest time passed to Touch.
|
||||
func (g *Game) LastActivity() time.Time {
|
||||
return g.lastActivity
|
||||
}
|
||||
|
||||
// IsClaimed reports whether a registered user claimed either slot.
|
||||
func (g *Game) IsClaimed() bool {
|
||||
for _, player := range g.players {
|
||||
if player != nil && player.ClaimedBy != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *Game) EndTimeUTC() *time.Time {
|
||||
if g.endTimeUTC == nil {
|
||||
return nil
|
||||
@@ -278,11 +294,6 @@ func (g *Game) GetSlotOwner(color core.Color) string {
|
||||
return player.ClaimedBy
|
||||
}
|
||||
|
||||
// IsSlotClaimedBy checks if a specific user owns the slot
|
||||
func (g *Game) IsSlotClaimedBy(color core.Color, userID string) bool {
|
||||
return g.GetSlotOwner(color) == userID
|
||||
}
|
||||
|
||||
// HasComputerPlayer returns true if at least one player is computer
|
||||
func (g *Game) HasComputerPlayer() bool {
|
||||
white := g.players[core.ColorWhite]
|
||||
|
||||
@@ -91,22 +91,22 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
|
||||
req.Email = strings.ToLower(req.Email)
|
||||
}
|
||||
|
||||
// Create the user and initial session atomically (temp by default via API).
|
||||
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password, false)
|
||||
// Create the user and initial session atomically.
|
||||
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrPermanentSlotsFull) {
|
||||
if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrAuthBusy) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "registration temporarily unavailable",
|
||||
Code: core.ErrResourceLimit,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
if errors.Is(err, service.ErrUserExists) {
|
||||
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
|
||||
Error: "user already exists",
|
||||
Code: core.ErrInvalidRequest,
|
||||
@@ -194,6 +194,11 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
if errors.Is(err, service.ErrAuthBusy) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication temporarily unavailable", Code: core.ErrResourceLimit,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid credentials",
|
||||
Code: core.ErrInvalidRequest,
|
||||
@@ -214,7 +219,7 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
|
||||
UserID: user.UserID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(service.SessionTTL),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
@@ -30,21 +29,58 @@ func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandle
|
||||
return &HTTPHandler{proc: proc, svc: svc}
|
||||
}
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRequests bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc, svc)
|
||||
// Options configures the API application.
|
||||
type Options struct {
|
||||
DevMode bool
|
||||
LogRequests bool
|
||||
// TrustedProxies lists reverse-proxy IPs or CIDRs whose ProxyHeader value
|
||||
// is the client address. Empty means the TCP peer is the client.
|
||||
TrustedProxies []string
|
||||
// ProxyHeader names a single-address header the proxy overwrites, such as
|
||||
// X-Real-IP (nginx: proxy_set_header X-Real-IP $remote_addr). Avoid
|
||||
// X-Forwarded-For: its first entry is supplied by the client.
|
||||
ProxyHeader string
|
||||
}
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
// appConfig returns the Fiber configuration. Client-IP resolution only honors
|
||||
// ProxyHeader on connections from a trusted proxy, so rate-limit keys cannot be
|
||||
// chosen by a client that reaches the API directly.
|
||||
func appConfig(opts Options) fiber.Config {
|
||||
config := fiber.Config{
|
||||
ErrorHandler: customErrorHandler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 35 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
})
|
||||
}
|
||||
if len(opts.TrustedProxies) > 0 && opts.ProxyHeader != "" {
|
||||
config.EnableTrustedProxyCheck = true
|
||||
config.TrustedProxies = opts.TrustedProxies
|
||||
config.ProxyHeader = opts.ProxyHeader
|
||||
config.EnableIPValidation = true
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// clientIP is the rate-limit key: the proxy-reported address from a trusted
|
||||
// proxy, otherwise the TCP peer.
|
||||
func clientIP(c *fiber.Ctx) string {
|
||||
if ip := c.IP(); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return c.Context().RemoteIP().String()
|
||||
}
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc, svc)
|
||||
devMode := opts.DevMode
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(appConfig(opts))
|
||||
|
||||
// Global middleware (order matters)
|
||||
app.Use(recover.New())
|
||||
if logRequests {
|
||||
if opts.LogRequests {
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n",
|
||||
TimeFormat: time.RFC3339,
|
||||
@@ -70,9 +106,7 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
|
||||
auth.Post("/register", limiter.New(limiter.Config{
|
||||
Max: 5,
|
||||
Expiration: 1 * time.Minute,
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
return c.IP()
|
||||
},
|
||||
KeyGenerator: clientIP,
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
@@ -86,9 +120,7 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
|
||||
auth.Post("/login", limiter.New(limiter.Config{
|
||||
Max: 10,
|
||||
Expiration: 1 * time.Minute,
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
return c.IP()
|
||||
},
|
||||
KeyGenerator: clientIP,
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
@@ -115,15 +147,7 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
|
||||
api.Use(limiter.New(limiter.Config{
|
||||
Max: maxReq,
|
||||
Expiration: 1 * time.Second,
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
if xff := c.Get("X-Forwarded-For"); xff != "" {
|
||||
if idx := strings.Index(xff, ","); idx != -1 {
|
||||
return strings.TrimSpace(xff[:idx])
|
||||
}
|
||||
return xff
|
||||
}
|
||||
return c.IP()
|
||||
},
|
||||
KeyGenerator: clientIP,
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestClientIPTrustsProxyHeaderOnlyFromTrustedPeers(t *testing.T) {
|
||||
// app.Test connections originate from 0.0.0.0.
|
||||
for name, test := range map[string]struct {
|
||||
opts Options
|
||||
want string
|
||||
}{
|
||||
"no proxy configured": {Options{ProxyHeader: "X-Real-IP"}, "0.0.0.0"},
|
||||
"untrusted peer": {Options{TrustedProxies: []string{"10.0.0.1"}, ProxyHeader: "X-Real-IP"}, "0.0.0.0"},
|
||||
"trusted peer": {Options{TrustedProxies: []string{"0.0.0.0/32"}, ProxyHeader: "X-Real-IP"}, "203.0.113.9"},
|
||||
"trusted, header only": {Options{TrustedProxies: []string{"0.0.0.0"}, ProxyHeader: "X-Real-IP"}, "203.0.113.9"},
|
||||
} {
|
||||
app := fiber.New(appConfig(test.opts))
|
||||
app.Get("/ip", func(c *fiber.Ctx) error { return c.SendString(clientIP(c)) })
|
||||
request := httptest.NewRequest("GET", "/ip", nil)
|
||||
request.Header.Set("X-Real-IP", "203.0.113.9")
|
||||
request.Header.Set("X-Forwarded-For", "198.51.100.1") // never consulted
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if string(body) != test.want {
|
||||
t.Errorf("%s: client IP = %q, want %q", name, body, test.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A trusted proxy that omits the header must not collapse clients into one key.
|
||||
app := fiber.New(appConfig(Options{TrustedProxies: []string{"0.0.0.0"}, ProxyHeader: "X-Real-IP"}))
|
||||
app.Get("/ip", func(c *fiber.Ctx) error { return c.SendString(clientIP(c)) })
|
||||
response, err := app.Test(httptest.NewRequest("GET", "/ip", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body, _ := io.ReadAll(response.Body); string(body) != "0.0.0.0" {
|
||||
t.Errorf("missing proxy header: client IP = %q, want peer address", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidUUIDRequiresCanonicalForm(t *testing.T) {
|
||||
for value, want := range map[string]bool{
|
||||
"5579b47e-4d3b-4eb3-abbd-846fe94cb955": true,
|
||||
"{5579b47e-4d3b-4eb3-abbd-846fe94cb955}": false,
|
||||
"urn:uuid:5579b47e-4d3b-4eb3-abbd-846fe94cb955": false,
|
||||
"5579b47e4d3b4eb3abbd846fe94cb955": false,
|
||||
"not-a-uuid": false,
|
||||
} {
|
||||
if got := isValidUUID(value); got != want {
|
||||
t.Errorf("isValidUUID(%q) = %v, want %v", value, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
// TokenValidator validates JWT tokens
|
||||
@@ -16,14 +16,40 @@ type TokenValidator func(token string) (userID string, claims map[string]any, er
|
||||
// AuthRequired enforces JWT authentication for protected endpoints
|
||||
func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
token := extractBearerToken(c.Get("Authorization"))
|
||||
if token == "" {
|
||||
header := c.Get(fiber.HeaderAuthorization)
|
||||
if header == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "missing authorization token",
|
||||
Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
token, err := auth.ParseBearerToken(header)
|
||||
if err != nil {
|
||||
return invalidToken(c)
|
||||
}
|
||||
|
||||
return authenticate(c, validateToken, token)
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth permits an absent Authorization header but rejects a malformed
|
||||
// or invalid one instead of silently downgrading an intended authenticated
|
||||
// request to anonymous access.
|
||||
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
header := c.Get(fiber.HeaderAuthorization)
|
||||
if header == "" {
|
||||
return c.Next()
|
||||
}
|
||||
token, err := auth.ParseBearerToken(header)
|
||||
if err != nil {
|
||||
return invalidToken(c)
|
||||
}
|
||||
return authenticate(c, validateToken, token)
|
||||
}
|
||||
}
|
||||
|
||||
func authenticate(c *fiber.Ctx, validateToken TokenValidator, token string) error {
|
||||
userID, claims, err := validateToken(token)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
@@ -31,10 +57,7 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token",
|
||||
Code: core.ErrUnauthorized,
|
||||
})
|
||||
return invalidToken(c)
|
||||
}
|
||||
|
||||
c.Locals("userID", userID)
|
||||
@@ -42,43 +65,10 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||
c.Locals("sessionID", sessionID)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth permits an absent token but rejects an invalid token instead of
|
||||
// silently downgrading an intended authenticated request to anonymous access.
|
||||
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
token := extractBearerToken(c.Get("Authorization"))
|
||||
if token == "" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
userID, claims, err := validateToken(token)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
func invalidToken(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token", Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
c.Locals("userID", userID)
|
||||
if sessionID, ok := claims["session_id"].(string); ok {
|
||||
c.Locals("sessionID", sessionID)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// extractBearerToken extracts JWT token from Authorization header
|
||||
func extractBearerToken(header string) string {
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(header, prefix)
|
||||
}
|
||||
@@ -58,3 +58,36 @@ func TestAuthMiddlewareReportsStorageUnavailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareParsesBearerHeaderStrictly(t *testing.T) {
|
||||
validate := func(token string) (string, map[string]any, error) {
|
||||
if token != "good.token.value" {
|
||||
return "", nil, errors.New("invalid token")
|
||||
}
|
||||
return "user", map[string]any{"session_id": "session"}, nil
|
||||
}
|
||||
for _, middleware := range []func(TokenValidator) fiber.Handler{AuthRequired, OptionalAuth} {
|
||||
app := fiber.New()
|
||||
app.Get("/route", middleware(validate), func(c *fiber.Ctx) error {
|
||||
return c.SendString(c.Locals("userID").(string))
|
||||
})
|
||||
for header, want := range map[string]int{
|
||||
"Bearer good.token.value": fiber.StatusOK,
|
||||
"bearer good.token.value": fiber.StatusOK, // RFC 6750 scheme is case-insensitive
|
||||
"Basic dXNlcjpwYXNz": fiber.StatusUnauthorized,
|
||||
"Bearer good token": fiber.StatusUnauthorized,
|
||||
"Bearer ": fiber.StatusUnauthorized,
|
||||
"good.token.value": fiber.StatusUnauthorized,
|
||||
} {
|
||||
request := httptest.NewRequest("GET", "/route", nil)
|
||||
request.Header.Set("Authorization", header)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != want {
|
||||
t.Errorf("header %q: status = %d, want %d", header, response.StatusCode, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,13 @@ func validationMiddleware(c *fiber.Ctx) error {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// isValidUUID accepts only the canonical 36-character form the server issues.
|
||||
// uuid.Parse also accepts braced, URN, and unhyphenated forms, which never
|
||||
// match an in-memory game and are rejected by the database's uuid input.
|
||||
func isValidUUID(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
_, err := uuid.Parse(s)
|
||||
return err == nil
|
||||
}
|
||||
@@ -531,25 +531,6 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
|
||||
})
|
||||
}
|
||||
|
||||
// determineGameEndState centralized function to determine game end state based on engine evaluation
|
||||
func (p *Processor) determineGameEndState(lastMoveBy core.Color, searchResult *engine.SearchResult) core.State {
|
||||
// No legal moves detected
|
||||
if searchResult.BestMove == "" || searchResult.BestMove == "(none)" {
|
||||
if searchResult.IsMate {
|
||||
// It's a checkmate - the side that just moved wins
|
||||
if lastMoveBy == core.ColorWhite {
|
||||
return core.StateWhiteWins
|
||||
}
|
||||
return core.StateBlackWins
|
||||
}
|
||||
// Stalemate - no legal moves but not in check
|
||||
return core.StateStalemate
|
||||
}
|
||||
|
||||
// Game continues
|
||||
return core.StateOngoing
|
||||
}
|
||||
|
||||
// classifyCurrentLocked classifies whatever position is loaded in the
|
||||
// validation engine. Caller holds p.mu, immediately after a SetPosition.
|
||||
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) {
|
||||
@@ -585,26 +566,6 @@ func (p *Processor) classifyLocked(fen string) (string, core.State, error) {
|
||||
return p.classifyCurrentLocked()
|
||||
}
|
||||
|
||||
// checkGameEnd: retry once (second attempt runs on a respawned process), then
|
||||
// fail SAFE to StateStuck. Leaving a possibly-terminal position Ongoing is the
|
||||
// original bug class; Stuck is now recoverable via undo (see handleUndoMove).
|
||||
func (p *Processor) checkGameEnd(gameID, fen string) {
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
p.mu.Lock()
|
||||
_, state, err := p.classifyLocked(fen)
|
||||
p.mu.Unlock()
|
||||
if err == nil {
|
||||
if state != core.StateOngoing {
|
||||
p.svc.UpdateGameState(gameID, state)
|
||||
}
|
||||
return
|
||||
}
|
||||
slog.Warn("game end-state check failed",
|
||||
"game_id", gameID, "attempt", attempt+1, "error", err)
|
||||
}
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
}
|
||||
|
||||
// buildGameResponse constructs standard game response
|
||||
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
|
||||
resp := core.GameResponse{
|
||||
|
||||
@@ -127,6 +127,7 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
|
||||
}
|
||||
|
||||
g.UpdatePlayers(whitePlayer, blackPlayer)
|
||||
g.Touch(time.Now().UTC())
|
||||
if oldHasComputer != newHasComputer {
|
||||
if newHasComputer {
|
||||
s.computerGames.Add(1)
|
||||
@@ -302,20 +303,6 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLastMoveResult stores metadata about the last move
|
||||
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
|
||||
g.SetLastResult(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UndoMoves removes the specified number of moves from game history
|
||||
func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
s.mu.Lock()
|
||||
@@ -334,6 +321,7 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
if err := g.UndoMoves(count); err != nil {
|
||||
return err
|
||||
}
|
||||
g.Touch(time.Now().UTC())
|
||||
|
||||
// Notify waiting clients about the undo
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), g.State())
|
||||
|
||||
@@ -2,21 +2,24 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/game"
|
||||
"chess/internal/server/storage"
|
||||
"chess/internal/server/storage/pgtest"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID, userID := uuid.NewString(), uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -24,53 +27,54 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
|
||||
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
|
||||
commit := MoveCommit{
|
||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||
ActorUserID: "user-1", MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
|
||||
ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
|
||||
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
|
||||
}
|
||||
if err := svc.ApplyMoveWithState("game-1", commit); err != nil {
|
||||
if err := svc.ApplyMoveWithState(gameID, commit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := svc.GetGameView("game-1")
|
||||
view, err := svc.GetGameView(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.WhitePlayer.ClaimedBy != "user-1" || view.State != core.StateWhiteWins {
|
||||
if view.WhitePlayer.ClaimedBy != userID || view.State != core.StateWhiteWins {
|
||||
t.Fatalf("in-memory move did not settle atomically: %+v", view)
|
||||
}
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
history, err := svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ClaimedBy != "user-1" || history.Result != "white_wins" || len(history.Moves) != 1 {
|
||||
if history.Players.White.ClaimedBy != userID || history.Result != "white_wins" || len(history.Moves) != 1 {
|
||||
t.Fatalf("history did not settle atomically: %+v", history)
|
||||
}
|
||||
|
||||
if err := svc.ApplyMoveWithState("game-1", commit); !errors.Is(err, ErrGameChanged) {
|
||||
if err := svc.ApplyMoveWithState(gameID, commit); !errors.Is(err, ErrGameChanged) {
|
||||
t.Fatalf("stale move error = %v, want ErrGameChanged", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoClearsDurableTerminalResult(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID := uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ApplyMoveWithState("game-1", MoveCommit{
|
||||
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
|
||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.UndoMoves("game-1", 1); err != nil {
|
||||
if err := svc.UndoMoves(gameID, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
history, err := svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -81,12 +85,13 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
|
||||
|
||||
func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID, userID := uuid.NewString(), uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
white.ID = "user-1"
|
||||
white.ClaimedBy = "user-1"
|
||||
white.ID = userID
|
||||
white.ClaimedBy = userID
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -95,15 +100,15 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
|
||||
replacementBlack := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack,
|
||||
)
|
||||
if err := svc.UpdatePlayers("game-1", replacementWhite, replacementBlack); err != nil {
|
||||
if err := svc.UpdatePlayers(gameID, replacementWhite, replacementBlack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
history, err := svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ID != "user-1" || history.Players.White.ClaimedBy != "user-1" {
|
||||
if history.Players.White.ID != userID || history.Players.White.ClaimedBy != userID {
|
||||
t.Fatalf("white ownership was replaced: %+v", history.Players.White)
|
||||
}
|
||||
if history.Players.Black.Type != core.PlayerComputer || history.Players.Black.Level != 12 {
|
||||
@@ -113,48 +118,101 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
|
||||
computerWhite := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite,
|
||||
)
|
||||
if err := svc.UpdatePlayers("game-1", computerWhite, replacementBlack); err != nil {
|
||||
if err := svc.UpdatePlayers(gameID, computerWhite, replacementBlack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err = svc.GetGameHistory("game-1")
|
||||
history, err = svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ClaimedBy != "user-1" {
|
||||
if history.Players.White.ClaimedBy != userID {
|
||||
t.Fatalf("player-type change discarded historical ownership: %+v", history.Players.White)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID := uuid.NewString()
|
||||
svc.SetFinishedGameTTL(time.Nanosecond)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "terminal", core.ColorWhite, core.StateStalemate,
|
||||
gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc.cleanupFinishedGames(time.Now().UTC().Add(time.Second))
|
||||
if _, err := svc.GetGameView("game-1"); !errors.Is(err, ErrGameNotFound) {
|
||||
svc.cleanupGames(time.Now().UTC().Add(time.Second))
|
||||
if _, err := svc.GetGameView(gameID); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("terminal game remains in memory: %v", err)
|
||||
}
|
||||
if history, err := svc.GetGameHistory("game-1"); err != nil || history.Result != "stalemate" {
|
||||
if history, err := svc.GetGameHistory(gameID); err != nil || history.Result != "stalemate" {
|
||||
t.Fatalf("durable history was removed: history=%+v err=%v", history, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
svc.SetFinishedGameTTL(0)
|
||||
user, _, err := svc.RegisterUser("alice", "", "Password1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
anonymous, claimed := uuid.NewString(), uuid.NewString()
|
||||
for gameID, claimant := range map[string]string{anonymous: "", claimed: user.UserID} {
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
if claimant != "" {
|
||||
white.ID, white.ClaimedBy = claimant, claimant
|
||||
}
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
history, err := svc.GetGameHistory(claimed)
|
||||
if err != nil || history.Players.White.Name != "alice" {
|
||||
t.Fatalf("claimed history name = %+v, %v", history, err)
|
||||
}
|
||||
|
||||
// Within the retention window both games stay loaded and stored.
|
||||
svc.cleanupGames(time.Now().UTC().Add(time.Hour))
|
||||
for _, gameID := range []string{anonymous, claimed} {
|
||||
if _, err := svc.GetGameView(gameID); err != nil {
|
||||
t.Fatalf("game %s evicted early: %v", gameID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Past it, the anonymous game leaves memory and the database; the
|
||||
// registered user's game is untouched.
|
||||
svc.cleanupGames(time.Now().UTC().Add(AnonymousGameTTL + time.Hour))
|
||||
if _, err := svc.GetGameView(anonymous); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("idle anonymous game still loaded: %v", err)
|
||||
}
|
||||
if _, err := svc.GetGameHistory(anonymous); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("idle anonymous game still stored: %v", err)
|
||||
}
|
||||
if _, err := svc.GetGameView(claimed); err != nil {
|
||||
t.Fatalf("claimed game evicted: %v", err)
|
||||
}
|
||||
if _, err := svc.GetGameHistory(claimed); err != nil {
|
||||
t.Fatalf("claimed game deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newPersistentTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
store, err := storage.NewStore(filepath.Join(t.TempDir(), "chess.db"), false)
|
||||
store, err := storage.NewStore(pgtest.DSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(store, []byte("test-secret-test-secret-test-secret"))
|
||||
svc, err := New(store, testJWTSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := svc.Shutdown(time.Second); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
|
||||
@@ -82,6 +82,7 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
|
||||
StartTimeUTC: record.StartTimeUTC,
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
MoveCount: record.MoveCount,
|
||||
FinalFEN: record.FinalFEN,
|
||||
Players: playersResponse(record.GameRecord),
|
||||
})
|
||||
}
|
||||
@@ -100,10 +101,12 @@ func playersResponse(record storage.GameRecord) core.PlayersResponse {
|
||||
White: &core.Player{
|
||||
ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType),
|
||||
Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy,
|
||||
Name: record.WhiteName,
|
||||
},
|
||||
Black: &core.Player{
|
||||
ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType),
|
||||
Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy,
|
||||
Name: record.BlackName,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"chess/internal/server/core"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,41 +9,62 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/game"
|
||||
"chess/internal/server/storage"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxComputerGames = 10
|
||||
MaxUsers = 100
|
||||
PermanentSlots = 10
|
||||
TempUserTTL = 24 * time.Hour
|
||||
DefaultMaxUsers = 100
|
||||
SessionTTL = 7 * 24 * time.Hour
|
||||
CleanupJobInterval = 1 * time.Hour
|
||||
FinishedGameTTL = 1 * time.Hour
|
||||
// AnonymousGameTTL is how long a game no registered user claimed survives
|
||||
// after its last activity, in memory and in the database.
|
||||
AnonymousGameTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Service coordinates game state, user management, and storage
|
||||
type Service struct {
|
||||
games map[string]*game.Game
|
||||
mu sync.RWMutex
|
||||
userMu sync.Mutex
|
||||
store *storage.Store
|
||||
jwtSecret []byte
|
||||
jwt *auth.JWT
|
||||
kdf chan struct{} // Argon2id concurrency slots
|
||||
waiter *WaitRegistry
|
||||
computerGames atomic.Int32 // Active games with computer players
|
||||
finishedTTL time.Duration
|
||||
anonymousTTL time.Duration
|
||||
maxUsers atomic.Int64
|
||||
}
|
||||
|
||||
// New creates a new service instance with optional storage
|
||||
func New(store *storage.Store, jwtSecret []byte) *Service {
|
||||
return &Service{
|
||||
// New creates a service with optional storage. jwtSecret signs session tokens
|
||||
// and must hold at least 32 bytes of key material.
|
||||
func New(store *storage.Store, jwtSecret []byte) (*Service, error) {
|
||||
manager, err := auth.NewJWT(jwtSecret,
|
||||
auth.WithIssuer(JWTIssuer),
|
||||
auth.WithAudience([]string{JWTAudience}),
|
||||
auth.WithTokenLifetime(SessionTTL),
|
||||
// Tokens are minted and verified by this process: no clock skew.
|
||||
auth.WithLeeway(0),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configure JWT: %w", err)
|
||||
}
|
||||
s := &Service{
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
jwtSecret: jwtSecret,
|
||||
jwt: manager,
|
||||
kdf: make(chan struct{}, MaxConcurrentKDF),
|
||||
waiter: NewWaitRegistry(),
|
||||
finishedTTL: FinishedGameTTL,
|
||||
anonymousTTL: AnonymousGameTTL,
|
||||
}
|
||||
s.maxUsers.Store(DefaultMaxUsers)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetFinishedGameTTL configures how long terminal games remain in memory.
|
||||
@@ -56,6 +76,22 @@ func (s *Service) SetFinishedGameTTL(ttl time.Duration) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetAnonymousGameTTL configures how long games without a registered player
|
||||
// survive after their last activity. Such games are unloaded from memory and
|
||||
// deleted from the database once idle this long. A non-positive duration
|
||||
// keeps them indefinitely.
|
||||
func (s *Service) SetAnonymousGameTTL(ttl time.Duration) {
|
||||
s.mu.Lock()
|
||||
s.anonymousTTL = ttl
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetMaxUsers caps the number of accounts public registration may create;
|
||||
// zero removes the cap. Accounts created through the CLI are not limited.
|
||||
func (s *Service) SetMaxUsers(limit int) {
|
||||
s.maxUsers.Store(int64(max(limit, 0)))
|
||||
}
|
||||
|
||||
// GetStorageHealth returns the storage component status
|
||||
func (s *Service) GetStorageHealth() string {
|
||||
if s.store == nil {
|
||||
@@ -84,60 +120,11 @@ func (s *Service) CanCreateComputerGame() bool {
|
||||
return s.computerGames.Load() < MaxComputerGames
|
||||
}
|
||||
|
||||
// IncrementComputerGames increments the computer game counter
|
||||
func (s *Service) IncrementComputerGames() {
|
||||
s.computerGames.Add(1)
|
||||
}
|
||||
|
||||
// DecrementComputerGames decrements the computer game counter
|
||||
func (s *Service) DecrementComputerGames() {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
|
||||
// GetComputerGameCount returns current computer game count
|
||||
func (s *Service) GetComputerGameCount() int32 {
|
||||
return s.computerGames.Load()
|
||||
}
|
||||
|
||||
// ClaimGameSlot claims a player slot for a user
|
||||
func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string) error {
|
||||
if userID == "" {
|
||||
return errors.New("claimant user ID is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
if err := g.ClaimSlot(color, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.store != nil {
|
||||
if err := s.store.RecordSlotClaim(gameID, color.String(), userID); err != nil {
|
||||
slog.Error("failed to queue slot claim persistence",
|
||||
"game_id", gameID, "color", color.String(), "error", err)
|
||||
}
|
||||
}
|
||||
slog.Debug("game slot claimed", "game_id", gameID, "color", color.String(), "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSlotOwner returns the user who claimed a slot
|
||||
func (s *Service) GetSlotOwner(gameID string, color core.Color) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
return g.GetSlotOwner(color), nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the service
|
||||
func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
var errs []error
|
||||
@@ -159,7 +146,8 @@ func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// RunCleanupJob runs periodic cleanup of expired users and sessions
|
||||
// RunCleanupJob periodically removes expired sessions, unloads idle games, and
|
||||
// deletes anonymous games past their retention.
|
||||
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
s.cleanupExpired()
|
||||
ticker := time.NewTicker(interval)
|
||||
@@ -176,49 +164,62 @@ func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
}
|
||||
|
||||
func (s *Service) cleanupExpired() {
|
||||
now := time.Now().UTC()
|
||||
if s.store != nil {
|
||||
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired users", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired temporary users", "count", deleted)
|
||||
}
|
||||
|
||||
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired sessions", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired sessions", "count", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
s.cleanupFinishedGames(time.Now().UTC())
|
||||
s.cleanupGames(now)
|
||||
}
|
||||
|
||||
func (s *Service) cleanupFinishedGames(now time.Time) {
|
||||
// cleanupGames unloads terminal games after the finished-game TTL and
|
||||
// unclaimed games idle past the anonymous TTL, then queues deletion of
|
||||
// anonymous games idle past that TTL. The set of games still loaded is taken
|
||||
// under the same lock as the eviction and excluded from deletion, so no live
|
||||
// game can lose its row while writes for it are still possible.
|
||||
func (s *Service) cleanupGames(now time.Time) {
|
||||
s.mu.Lock()
|
||||
if s.finishedTTL <= 0 {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
cutoff := now.Add(-s.finishedTTL)
|
||||
removed := make([]string, 0)
|
||||
finishedCutoff := now.Add(-s.finishedTTL)
|
||||
anonymousTTL := s.anonymousTTL
|
||||
anonymousCutoff := now.Add(-anonymousTTL)
|
||||
var finished, idle []string
|
||||
live := make([]string, 0, len(s.games))
|
||||
for gameID, g := range s.games {
|
||||
ended := g.EndTimeUTC()
|
||||
if !g.State().IsTerminal() || ended == nil || ended.After(cutoff) {
|
||||
switch {
|
||||
case s.finishedTTL > 0 && g.State().IsTerminal() && ended != nil && ended.Before(finishedCutoff):
|
||||
finished = append(finished, gameID)
|
||||
case anonymousTTL > 0 && !g.IsClaimed() && g.State() != core.StatePending &&
|
||||
g.LastActivity().Before(anonymousCutoff):
|
||||
idle = append(idle, gameID)
|
||||
default:
|
||||
live = append(live, gameID)
|
||||
continue
|
||||
}
|
||||
if g.HasComputerPlayer() {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
delete(s.games, gameID)
|
||||
removed = append(removed, gameID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, gameID := range removed {
|
||||
for _, gameID := range append(finished, idle...) {
|
||||
s.waiter.RemoveGame(gameID)
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
if len(finished) > 0 {
|
||||
slog.Info("cleanup evicted terminal games from memory",
|
||||
"count", len(removed), "retention", s.finishedTTL)
|
||||
"count", len(finished), "retention", s.finishedTTL)
|
||||
}
|
||||
if len(idle) > 0 {
|
||||
slog.Info("cleanup evicted idle anonymous games from memory",
|
||||
"count", len(idle), "retention", anonymousTTL)
|
||||
}
|
||||
if s.store != nil && anonymousTTL > 0 {
|
||||
if err := s.store.DeleteAnonymousGames(anonymousCutoff, live); err != nil {
|
||||
slog.Error("cleanup failed to queue anonymous game deletion", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
-176
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/storage"
|
||||
@@ -18,122 +19,129 @@ var (
|
||||
ErrStorageDisabled = errors.New("storage disabled")
|
||||
ErrStorageUnavailable = errors.New("storage unavailable")
|
||||
ErrAtCapacity = errors.New("at capacity")
|
||||
ErrPermanentSlotsFull = errors.New("permanent slots full")
|
||||
ErrUserExists = errors.New("username or email already exists")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrAuthBusy = errors.New("authentication capacity exhausted")
|
||||
)
|
||||
|
||||
const (
|
||||
// JWTIssuer and JWTAudience scope chess tokens so a token minted by another
|
||||
// service sharing a key is rejected.
|
||||
JWTIssuer = "chess-server"
|
||||
JWTAudience = "chess-api"
|
||||
|
||||
// MaxConcurrentKDF bounds simultaneous Argon2id derivations (64 MiB each at
|
||||
// the auth defaults), capping password-hashing memory near 256 MiB.
|
||||
MaxConcurrentKDF = 4
|
||||
kdfWaitTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// dummyPasswordHash is verified for unknown identifiers so a login for a
|
||||
// missing account costs the same Argon2id work as one with a wrong password.
|
||||
var dummyPasswordHash = sync.OnceValues(func() (string, error) {
|
||||
return auth.HashPassword(uuid.NewString())
|
||||
})
|
||||
|
||||
// User represents a registered user account
|
||||
type User struct {
|
||||
UserID string
|
||||
Username string
|
||||
Email string
|
||||
AccountType string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// CreateUser creates new user with registration limits enforcement
|
||||
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
|
||||
user, _, err := s.createUser(username, email, password, permanent, false)
|
||||
return user, err
|
||||
// acquireKDF reserves one Argon2id slot, waiting at most kdfWaitTimeout.
|
||||
func (s *Service) acquireKDF() (release func(), err error) {
|
||||
timer := time.NewTimer(kdfWaitTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case s.kdf <- struct{}{}:
|
||||
return func() { <-s.kdf }, nil
|
||||
case <-timer.C:
|
||||
return nil, ErrAuthBusy
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterUser creates the account and its initial session in one SQLite
|
||||
func (s *Service) hashPassword(password string) (string, error) {
|
||||
release, err := s.acquireKDF()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
return auth.HashPassword(password)
|
||||
}
|
||||
|
||||
func (s *Service) verifyPassword(password, hash string) error {
|
||||
release, err := s.acquireKDF()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
return auth.VerifyPassword(password, hash)
|
||||
}
|
||||
|
||||
// RegisterUser creates the account and its initial session in one database
|
||||
// transaction, so a successful registration always returns a usable account.
|
||||
func (s *Service) RegisterUser(username, email, password string, permanent bool) (*User, string, error) {
|
||||
return s.createUser(username, email, password, permanent, true)
|
||||
}
|
||||
|
||||
func (s *Service) createUser(
|
||||
username, email, password string,
|
||||
permanent, withSession bool,
|
||||
) (*User, string, error) {
|
||||
s.userMu.Lock()
|
||||
defer s.userMu.Unlock()
|
||||
|
||||
// Registered accounts are identical to CLI-created ones and never expire.
|
||||
func (s *Service) RegisterUser(username, email, password string) (*User, string, error) {
|
||||
if s.store == nil {
|
||||
return nil, "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
// Determine account type
|
||||
accountType := "temp"
|
||||
var expiresAt *time.Time
|
||||
|
||||
if permanent {
|
||||
accountType = "permanent"
|
||||
} else {
|
||||
expiry := time.Now().UTC().Add(TempUserTTL)
|
||||
expiresAt = &expiry
|
||||
}
|
||||
|
||||
// Hash password
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
// Hash before touching storage; account creation itself is serialized by
|
||||
// the store, so concurrent registrations only contend for KDF slots.
|
||||
passwordHash, err := s.hashPassword(password)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuthBusy) {
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Generate unique user ID
|
||||
userID, err := s.generateUniqueUserID()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate unique ID: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create user record
|
||||
// A random UUIDv4 needs no existence probe; the primary key rejects the
|
||||
// negligible collision case.
|
||||
userID := uuid.NewString()
|
||||
user := &User{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
AccountType: accountType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
record := storage.UserRecord{
|
||||
UserID: userID,
|
||||
Username: strings.ToLower(username),
|
||||
Email: strings.ToLower(email),
|
||||
PasswordHash: passwordHash,
|
||||
AccountType: accountType,
|
||||
CreatedAt: user.CreatedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
var sessionID string
|
||||
var session *storage.SessionRecord
|
||||
if withSession {
|
||||
sessionID = uuid.New().String()
|
||||
session = &storage.SessionRecord{
|
||||
record := storage.UserRecord{
|
||||
UserID: userID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
PasswordHash: passwordHash,
|
||||
CreatedAt: now,
|
||||
}
|
||||
sessionID := uuid.NewString()
|
||||
session := &storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: user.CreatedAt,
|
||||
ExpiresAt: user.CreatedAt.Add(SessionTTL),
|
||||
}
|
||||
}
|
||||
limits := storage.UserLimits{
|
||||
MaxUsers: MaxUsers,
|
||||
PermanentSlots: PermanentSlots,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(SessionTTL),
|
||||
}
|
||||
limits := storage.UserLimits{MaxUsers: int(s.maxUsers.Load())}
|
||||
if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrUserAlreadyExists):
|
||||
return nil, "", fmt.Errorf("username or email already exists: %w", err)
|
||||
case errors.Is(err, storage.ErrPermanentCapacity):
|
||||
return nil, "", fmt.Errorf("%w (%d maximum)", ErrPermanentSlotsFull, PermanentSlots)
|
||||
return nil, "", ErrUserExists
|
||||
case errors.Is(err, storage.ErrUserCapacity):
|
||||
return nil, "", fmt.Errorf("%w: no temporary account can be replaced", ErrAtCapacity)
|
||||
return nil, "", fmt.Errorf("%w: registration is limited to %d accounts", ErrAtCapacity, limits.MaxUsers)
|
||||
default:
|
||||
return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
}
|
||||
slog.Debug("user created",
|
||||
"user_id", userID,
|
||||
"account_type", accountType,
|
||||
"initial_session", withSession,
|
||||
)
|
||||
slog.Debug("user created", "user_id", userID)
|
||||
|
||||
return user, sessionID, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser verifies credentials and creates a new session
|
||||
// AuthenticateUser verifies credentials and creates a new session. Unknown
|
||||
// identifiers and wrong passwords both return ErrInvalidCredentials after the
|
||||
// same Argon2id work.
|
||||
func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
|
||||
if s.store == nil {
|
||||
return nil, "", ErrStorageDisabled
|
||||
@@ -141,75 +149,49 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
|
||||
|
||||
var userRecord *storage.UserRecord
|
||||
var err error
|
||||
|
||||
// Check if identifier looks like email
|
||||
if strings.Contains(identifier, "@") {
|
||||
userRecord, err = s.store.GetUserByEmail(identifier)
|
||||
} else {
|
||||
userRecord, err = s.store.GetUserByUsername(identifier)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
auth.HashPassword(password) // Timing attack prevention
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
|
||||
hash := ""
|
||||
if userRecord != nil {
|
||||
hash = userRecord.PasswordHash
|
||||
} else if hash, err = dummyPasswordHash(); err != nil {
|
||||
return nil, "", fmt.Errorf("prepare credential check: %w", err)
|
||||
}
|
||||
if err := s.verifyPassword(password, hash); err != nil || userRecord == nil {
|
||||
if errors.Is(err, ErrAuthBusy) {
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := auth.VerifyPassword(password, userRecord.PasswordHash); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// Check if temp user expired
|
||||
if userRecord.AccountType == "temp" && userRecord.ExpiresAt != nil {
|
||||
if time.Now().UTC().After(*userRecord.ExpiresAt) {
|
||||
return nil, "", fmt.Errorf("account expired")
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session (invalidates any existing session)
|
||||
sessionID := uuid.New().String()
|
||||
sessionRecord := storage.SessionRecord{
|
||||
now := time.Now().UTC()
|
||||
// Replaces any existing session and records the login time.
|
||||
sessionID := uuid.NewString()
|
||||
if err := s.store.CreateSession(storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userRecord.UserID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(SessionTTL),
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(SessionTTL),
|
||||
}); err != nil {
|
||||
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
slog.Debug("user authenticated", "user_id", userRecord.UserID)
|
||||
|
||||
// Update last login
|
||||
if err := s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC()); err != nil {
|
||||
slog.Warn("failed to record user login time", "user_id", userRecord.UserID, "error", err)
|
||||
}
|
||||
|
||||
return &User{
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
AccountType: userRecord.AccountType,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
ExpiresAt: userRecord.ExpiresAt,
|
||||
}, sessionID, nil
|
||||
}
|
||||
|
||||
// ValidateSession checks if a session is valid
|
||||
func (s *Service) ValidateSession(sessionID string) (bool, error) {
|
||||
if s.store == nil {
|
||||
return false, ErrStorageDisabled
|
||||
}
|
||||
valid, err := s.store.IsSessionValid(sessionID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: validate session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// InvalidateSession removes a session (logout)
|
||||
func (s *Service) InvalidateSession(sessionID string) error {
|
||||
if s.store == nil {
|
||||
@@ -239,31 +221,20 @@ func (s *Service) GetUserByID(userID string) (*User, error) {
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
AccountType: userRecord.AccountType,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
ExpiresAt: userRecord.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateUserToken creates a JWT token for the specified user with session ID
|
||||
// GenerateUserToken signs a scoped JWT bound to the persisted session. The
|
||||
// token carries only the subject and session ID: JWT payloads are readable by
|
||||
// the holder, so profile data such as email stays behind /auth/me.
|
||||
func (s *Service) GenerateUserToken(userID, sessionID string) (string, error) {
|
||||
user, err := s.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims := map[string]any{
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"session_id": sessionID,
|
||||
}
|
||||
|
||||
return auth.GenerateHS256Token(s.jwtSecret, userID, claims, SessionTTL)
|
||||
return s.jwt.GenerateToken(userID, map[string]any{"session_id": sessionID})
|
||||
}
|
||||
|
||||
// ValidateToken verifies JWT token and session validity
|
||||
func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
||||
userID, claims, err := auth.ValidateHS256Token(s.jwtSecret, token)
|
||||
userID, claims, err := s.jwt.ValidateToken(token)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -285,43 +256,3 @@ func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
||||
|
||||
return userID, claims, nil
|
||||
}
|
||||
|
||||
// generateUniqueUserID creates a unique user ID with collision detection
|
||||
func (s *Service) generateUniqueUserID() (string, error) {
|
||||
const maxAttempts = 10
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
id := uuid.New().String()
|
||||
if _, err := s.store.GetUserByID(id); errors.Is(err, sql.ErrNoRows) {
|
||||
return id, nil
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("%w: check generated user ID: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to generate unique user ID")
|
||||
}
|
||||
|
||||
// CreateUserSession creates a session for a trusted internal caller without
|
||||
// re-authenticating. Public registration uses RegisterUser so account and
|
||||
// initial session creation remain atomic.
|
||||
func (s *Service) CreateUserSession(userID string) (string, error) {
|
||||
if s.store == nil {
|
||||
return "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
sessionRecord := storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(SessionTTL),
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
return "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
slog.Debug("user session created", "user_id", userID)
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
var testJWTSecret = []byte("test-secret-test-secret-test-secret")
|
||||
|
||||
func TestValidateTokenRequiresScopedTokenBoundToSession(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
user, sessionID, err := svc.RegisterUser("alice", "", "Password1", false)
|
||||
user, sessionID, err := svc.RegisterUser("alice", "", "Password1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -17,26 +24,47 @@ func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotUserID, _, err := svc.ValidateToken(validToken); err != nil || gotUserID != user.UserID {
|
||||
t.Fatalf("valid token rejected: user=%q err=%v", gotUserID, err)
|
||||
gotUserID, claims, err := svc.ValidateToken(validToken)
|
||||
if err != nil || gotUserID != user.UserID || claims["session_id"] != sessionID {
|
||||
t.Fatalf("valid token rejected: user=%q claims=%v err=%v", gotUserID, claims, err)
|
||||
}
|
||||
|
||||
missingSession, err := auth.GenerateHS256Token(svc.jwtSecret, user.UserID, nil, SessionTTL)
|
||||
scoped, err := auth.NewJWT(testJWTSecret,
|
||||
auth.WithIssuer(JWTIssuer), auth.WithAudience([]string{JWTAudience}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(missingSession); err == nil {
|
||||
t.Fatal("token without a persisted session was accepted")
|
||||
}
|
||||
|
||||
wrongSubject, err := auth.GenerateHS256Token(svc.jwtSecret, "other-user", map[string]any{
|
||||
"session_id": sessionID,
|
||||
}, SessionTTL)
|
||||
unscoped, err := auth.NewJWT(testJWTSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(wrongSubject); err == nil {
|
||||
t.Fatal("session was accepted for a different JWT subject")
|
||||
foreignAudience, err := auth.NewJWT(testJWTSecret,
|
||||
auth.WithIssuer(JWTIssuer), auth.WithAudience([]string{"other-service"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mint := range map[string]func() (string, error){
|
||||
"missing session": func() (string, error) { return scoped.GenerateToken(user.UserID, nil) },
|
||||
"wrong subject": func() (string, error) {
|
||||
return scoped.GenerateToken(uuid.NewString(), map[string]any{"session_id": sessionID})
|
||||
},
|
||||
"malformed subject": func() (string, error) {
|
||||
return scoped.GenerateToken("other-user", map[string]any{"session_id": sessionID})
|
||||
},
|
||||
"no issuer or audience": func() (string, error) {
|
||||
return unscoped.GenerateToken(user.UserID, map[string]any{"session_id": sessionID})
|
||||
},
|
||||
"foreign audience": func() (string, error) {
|
||||
return foreignAudience.GenerateToken(user.UserID, map[string]any{"session_id": sessionID})
|
||||
},
|
||||
} {
|
||||
token, err := mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(token); err == nil {
|
||||
t.Errorf("%s: token accepted", name)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.InvalidateSession(sessionID); err != nil {
|
||||
@@ -46,3 +74,75 @@ func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
t.Fatal("invalidated session was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateUserFailuresAreIndistinguishable(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
if _, _, err := svc.RegisterUser("alice", "alice@example.com", "Password1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.RegisterUser("ALICE", "", "Password1"); !errors.Is(err, ErrUserExists) {
|
||||
t.Fatalf("duplicate registration = %v, want ErrUserExists", err)
|
||||
}
|
||||
|
||||
user, sessionID, err := svc.AuthenticateUser("alice@example.com", "Password1")
|
||||
if err != nil || sessionID == "" || user.Username != "alice" {
|
||||
t.Fatalf("login by email = %+v, %q, %v", user, sessionID, err)
|
||||
}
|
||||
|
||||
for name, attempt := range map[string][2]string{
|
||||
"unknown user": {"nobody", "Password1"},
|
||||
"unknown email": {"nobody@example.com", "Password1"},
|
||||
"wrong password": {"alice", "Password2"},
|
||||
} {
|
||||
if _, _, err := svc.AuthenticateUser(attempt[0], attempt[1]); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Errorf("%s: error = %v, want ErrInvalidCredentials", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationCapIsConfigurable(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
svc.SetMaxUsers(1)
|
||||
if _, _, err := svc.RegisterUser("alice", "", "Password1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.RegisterUser("bob", "", "Password1"); !errors.Is(err, ErrAtCapacity) {
|
||||
t.Fatalf("registration past the cap = %v, want ErrAtCapacity", err)
|
||||
}
|
||||
svc.SetMaxUsers(0)
|
||||
if _, _, err := svc.RegisterUser("bob", "", "Password1"); err != nil {
|
||||
t.Fatalf("registration without a cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHashingIsBounded(t *testing.T) {
|
||||
svc, err := New(nil, testJWTSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var releases []func()
|
||||
for range MaxConcurrentKDF {
|
||||
release, err := svc.acquireKDF()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releases = append(releases, release)
|
||||
}
|
||||
started := time.Now()
|
||||
if _, err := svc.hashPassword("Password1"); !errors.Is(err, ErrAuthBusy) {
|
||||
t.Fatalf("saturated KDF = %v, want ErrAuthBusy", err)
|
||||
}
|
||||
if waited := time.Since(started); waited < kdfWaitTimeout {
|
||||
t.Fatalf("gave up after %v, want %v", waited, kdfWaitTimeout)
|
||||
}
|
||||
releases[0]()
|
||||
if _, err := svc.hashPassword("Password1"); err != nil {
|
||||
t.Fatalf("hash after release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsShortJWTSecret(t *testing.T) {
|
||||
if _, err := New((*storage.Store)(nil), make([]byte, 31)); err == nil {
|
||||
t.Fatal("31-byte JWT secret accepted")
|
||||
}
|
||||
}
|
||||
+200
-210
@@ -6,17 +6,29 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const gameSelectColumns = `
|
||||
g.game_id, g.initial_fen,
|
||||
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by,
|
||||
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by,
|
||||
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name,
|
||||
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name,
|
||||
g.result, g.start_time_utc, g.end_time_utc`
|
||||
|
||||
// claimantName snapshots a claimant's username inside the writing
|
||||
// transaction; NULL when the slot is unclaimed or the account is gone.
|
||||
func claimantName(param string) string {
|
||||
return "(SELECT u.username FROM users u WHERE u.user_id = " + param + ")"
|
||||
}
|
||||
|
||||
// MaxUserGamesPage bounds QueryGamesForUser; callers request one extra row to
|
||||
// detect a following page.
|
||||
const MaxUserGamesPage = 101
|
||||
|
||||
// RecordNewGame asynchronously records a new game. Terminal custom-FEN games
|
||||
// include their result in this insert rather than relying on a second write.
|
||||
// Claimed slots also record the claimant's current username.
|
||||
func (s *Store) RecordNewGame(record GameRecord) error {
|
||||
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
|
||||
return errors.New("game ID, initial FEN, and player IDs are required")
|
||||
@@ -25,15 +37,16 @@ func (s *Store) RecordNewGame(record GameRecord) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.enqueue("record_game", record.GameID, func(tx *sql.Tx) error {
|
||||
const query = `INSERT INTO games (
|
||||
return s.enqueue("record_game", record.GameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
query := `INSERT INTO games (
|
||||
game_id, initial_fen,
|
||||
white_player_id, white_type, white_level, white_search_time, white_claimed_by,
|
||||
black_player_id, black_type, black_level, black_search_time, black_claimed_by,
|
||||
white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name,
|
||||
black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name,
|
||||
start_time_utc, result, end_time_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `,
|
||||
$8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15)`
|
||||
|
||||
_, err := tx.Exec(query,
|
||||
_, err := tx.ExecContext(ctx, query,
|
||||
record.GameID, record.InitialFEN,
|
||||
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
|
||||
nullableString(record.WhiteClaimedBy),
|
||||
@@ -65,11 +78,11 @@ func (s *Store) RecordMove(record MovePersistence) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.enqueue("record_move", record.Move.GameID, func(tx *sql.Tx) error {
|
||||
return s.enqueue("record_move", record.Move.GameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
const insertMove = `INSERT INTO moves (
|
||||
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
if _, err := tx.Exec(insertMove,
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)`
|
||||
if _, err := tx.ExecContext(ctx, insertMove,
|
||||
record.Move.GameID,
|
||||
record.Move.MoveNumber,
|
||||
record.Move.MoveUCI,
|
||||
@@ -80,33 +93,35 @@ func (s *Store) RecordMove(record MovePersistence) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if record.ClaimedBy != "" {
|
||||
column := "white_claimed_by"
|
||||
if record.ClaimedBy == "" && record.Result == "" {
|
||||
return nil
|
||||
}
|
||||
// One UPDATE applies the optional claim and optional result. A claim may
|
||||
// only fill an empty slot or repeat the same claimant.
|
||||
claimColumn, nameColumn := "white_claimed_by", "white_name"
|
||||
if record.ClaimColor == "b" {
|
||||
column = "black_claimed_by"
|
||||
claimColumn, nameColumn = "black_claimed_by", "black_name"
|
||||
}
|
||||
query := `UPDATE games SET ` + column + ` = ?
|
||||
WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)`
|
||||
result, err := tx.Exec(query, record.ClaimedBy, record.Move.GameID, record.ClaimedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
var set []string
|
||||
args := []any{record.Move.GameID}
|
||||
where := "game_id = $1"
|
||||
if record.ClaimedBy != "" {
|
||||
args = append(args, record.ClaimedBy)
|
||||
param := fmt.Sprintf("$%d", len(args))
|
||||
set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)",
|
||||
claimColumn, param, nameColumn, nameColumn, claimantName(param)))
|
||||
where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param)
|
||||
}
|
||||
if err := requireOneGame(result, record.Move.GameID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if record.Result != "" {
|
||||
result, err := tx.Exec(
|
||||
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`,
|
||||
record.Result, record.EndTimeUTC, record.Move.GameID,
|
||||
)
|
||||
args = append(args, record.Result, record.EndTimeUTC)
|
||||
set = append(set, fmt.Sprintf("result = $%d, end_time_utc = $%d", len(args)-1, len(args)))
|
||||
}
|
||||
result, err := tx.ExecContext(ctx,
|
||||
"UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(result, record.Move.GameID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,56 +137,10 @@ func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
|
||||
if at.IsZero() {
|
||||
return errors.New("game result time is required")
|
||||
}
|
||||
return s.enqueue("record_game_result", gameID, func(tx *sql.Tx) error {
|
||||
res, err := tx.Exec(
|
||||
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`,
|
||||
result, at.UTC(), gameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// RecordSlotClaim persists a claim made independently from a move.
|
||||
func (s *Store) RecordSlotClaim(gameID, color, userID string) error {
|
||||
if gameID == "" || userID == "" {
|
||||
return errors.New("game ID and claimant are required")
|
||||
}
|
||||
if color != "w" && color != "b" {
|
||||
return fmt.Errorf("invalid claim color %q", color)
|
||||
}
|
||||
column := "white_claimed_by"
|
||||
if color == "b" {
|
||||
column = "black_claimed_by"
|
||||
}
|
||||
return s.enqueue("record_slot_claim", gameID, func(tx *sql.Tx) error {
|
||||
query := `UPDATE games SET ` + column + ` = ?
|
||||
WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)`
|
||||
res, err := tx.Exec(query, userID, gameID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// RecordPlayers keeps persisted player configuration aligned with in-memory
|
||||
// configuration changes.
|
||||
func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
|
||||
if gameID == "" || white.PlayerID == "" || black.PlayerID == "" {
|
||||
return errors.New("game ID and player IDs are required")
|
||||
}
|
||||
return s.enqueue("record_players", gameID, func(tx *sql.Tx) error {
|
||||
const query = `UPDATE games SET
|
||||
white_player_id = ?, white_type = ?, white_level = ?, white_search_time = ?, white_claimed_by = ?,
|
||||
black_player_id = ?, black_type = ?, black_level = ?, black_search_time = ?, black_claimed_by = ?
|
||||
WHERE game_id = ?`
|
||||
res, err := tx.Exec(query,
|
||||
white.PlayerID, white.Type, white.Level, white.SearchTime, nullableString(white.ClaimedBy),
|
||||
black.PlayerID, black.Type, black.Level, black.SearchTime, nullableString(black.ClaimedBy),
|
||||
gameID,
|
||||
return s.enqueue("record_game_result", gameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE games SET result = $2, end_time_utc = $3 WHERE game_id = $1`,
|
||||
gameID, result, at.UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -189,21 +158,44 @@ type PlayerRecord struct {
|
||||
ClaimedBy string
|
||||
}
|
||||
|
||||
// RecordPlayers keeps persisted player configuration aligned with in-memory
|
||||
// configuration changes. Claims are carried over unchanged by the service, so
|
||||
// their name snapshots are left as they are.
|
||||
func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
|
||||
if gameID == "" || white.PlayerID == "" || black.PlayerID == "" {
|
||||
return errors.New("game ID and player IDs are required")
|
||||
}
|
||||
return s.enqueue("record_players", gameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
const query = `UPDATE games SET
|
||||
white_player_id = $2, white_type = $3, white_level = $4, white_search_time = $5, white_claimed_by = $6,
|
||||
black_player_id = $7, black_type = $8, black_level = $9, black_search_time = $10, black_claimed_by = $11
|
||||
WHERE game_id = $1`
|
||||
res, err := tx.ExecContext(ctx, query, gameID,
|
||||
white.PlayerID, white.Type, white.Level, white.SearchTime, nullableString(white.ClaimedBy),
|
||||
black.PlayerID, black.Type, black.Level, black.SearchTime, nullableString(black.ClaimedBy),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// RewindGame atomically removes undone moves and clears a previously terminal
|
||||
// result so replay readers never observe an ongoing line with a stale outcome.
|
||||
func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
|
||||
if gameID == "" || afterMoveNumber < 0 {
|
||||
return errors.New("game ID and a non-negative move number are required")
|
||||
}
|
||||
return s.enqueue("rewind_game", gameID, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM moves WHERE game_id = ? AND move_number > ?`,
|
||||
return s.enqueue("rewind_game", gameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM moves WHERE game_id = $1 AND move_number > $2`,
|
||||
gameID, afterMoveNumber,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(
|
||||
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = ?`,
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = $1`,
|
||||
gameID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -213,28 +205,66 @@ func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
|
||||
})
|
||||
}
|
||||
|
||||
// QueryGames retrieves games with optional filtering. A player filter matches
|
||||
// both creation-time player IDs and claims made after game creation.
|
||||
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
// DeleteAnonymousGames queues removal of games that no registered user
|
||||
// claimed and whose last activity (start, last move, or end) precedes cutoff.
|
||||
// Games still loaded in memory are listed in live and always kept, so a queued
|
||||
// or future write can never target a deleted row. Moves cascade.
|
||||
func (s *Store) DeleteAnonymousGames(cutoff time.Time, live []string) error {
|
||||
if cutoff.IsZero() {
|
||||
return errors.New("anonymous game cutoff is required")
|
||||
}
|
||||
started := time.Now()
|
||||
query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE 1=1`
|
||||
var args []any
|
||||
if live == nil {
|
||||
live = []string{}
|
||||
}
|
||||
return s.enqueue("delete_anonymous_games", "", func(ctx context.Context, tx *sql.Tx) error {
|
||||
result, err := tx.ExecContext(ctx, `DELETE FROM games g
|
||||
WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL
|
||||
AND g.start_time_utc < $1
|
||||
AND (g.end_time_utc IS NULL OR g.end_time_utc < $1)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM moves m
|
||||
WHERE m.game_id = g.game_id AND m.move_time_utc >= $1
|
||||
)
|
||||
AND NOT (g.game_id = ANY($2::uuid[]))`,
|
||||
cutoff.UTC(), live)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted, err := result.RowsAffected(); err == nil && deleted > 0 {
|
||||
slog.Info("storage anonymous games deleted", "count", deleted, "inactive_before", cutoff.UTC())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// QueryGames is the administrative game lookup. Either filter may be empty or
|
||||
// "*" for all; a player filter matches creation-time player IDs and claims.
|
||||
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE true`
|
||||
var args []any
|
||||
if gameID != "" && gameID != "*" {
|
||||
query += " AND g.game_id = ?"
|
||||
if !validUUID(gameID) {
|
||||
return nil, fmt.Errorf("invalid game ID %q", gameID)
|
||||
}
|
||||
args = append(args, gameID)
|
||||
query += fmt.Sprintf(" AND g.game_id = $%d", len(args))
|
||||
}
|
||||
if playerID != "" && playerID != "*" {
|
||||
query += ` AND (g.white_player_id = ? OR g.black_player_id = ?
|
||||
OR g.white_claimed_by = ? OR g.black_claimed_by = ?)`
|
||||
args = append(args, playerID, playerID, playerID, playerID)
|
||||
if !validUUID(playerID) {
|
||||
return nil, fmt.Errorf("invalid player ID %q", playerID)
|
||||
}
|
||||
args = append(args, playerID)
|
||||
query += fmt.Sprintf(` AND $%d IN (g.white_player_id, g.black_player_id,
|
||||
g.white_claimed_by, g.black_claimed_by)`, len(args))
|
||||
}
|
||||
query += " ORDER BY g.start_time_utc DESC, g.game_id DESC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query games: %w", err)
|
||||
}
|
||||
@@ -251,111 +281,95 @@ func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate games: %w", err)
|
||||
}
|
||||
slog.Debug("storage games queried", "count", len(games), "duration", time.Since(started))
|
||||
return games, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetGameRecord(gameID string) (*GameRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return getGameRecord(s.db, gameID)
|
||||
}
|
||||
|
||||
type gameQueryer interface {
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
func getGameRecord(queryer gameQueryer, gameID string) (*GameRecord, error) {
|
||||
var record GameRecord
|
||||
row := queryer.QueryRow(`SELECT `+gameSelectColumns+` FROM games g WHERE g.game_id = ?`, gameID)
|
||||
if err := scanGame(row, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// GetMovesForGame returns the complete, undo-consistent replay line.
|
||||
func (s *Store) GetMovesForGame(gameID string) ([]MoveRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return getMovesForGame(s.db, gameID)
|
||||
}
|
||||
|
||||
func getMovesForGame(queryer gameQueryer, gameID string) ([]MoveRecord, error) {
|
||||
const query = `SELECT move_id, game_id, move_number, move_uci,
|
||||
fen_after_move, player_color, move_time_utc
|
||||
FROM moves WHERE game_id = ? ORDER BY move_number ASC`
|
||||
rows, err := queryer.Query(query, gameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query game moves: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
moves := make([]MoveRecord, 0)
|
||||
for rows.Next() {
|
||||
var move MoveRecord
|
||||
if err := rows.Scan(
|
||||
&move.MoveID, &move.GameID, &move.MoveNumber, &move.MoveUCI,
|
||||
&move.FENAfterMove, &move.PlayerColor, &move.MoveTimeUTC,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan game move: %w", err)
|
||||
}
|
||||
moves = append(moves, move)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate game moves: %w", err)
|
||||
}
|
||||
return moves, nil
|
||||
}
|
||||
|
||||
// GetGameHistory uses one write barrier and one read transaction for a
|
||||
// consistent game-and-moves snapshot.
|
||||
// GetGameHistory returns the game row and its ordered moves after every
|
||||
// previously accepted write has committed. Both reads share one REPEATABLE READ
|
||||
// snapshot, so a concurrent move or rewind cannot produce a mixed history.
|
||||
func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error) {
|
||||
if !validUUID(gameID) {
|
||||
return nil, nil, sql.ErrNoRows
|
||||
}
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("begin game history read: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
record, err := getGameRecord(tx, gameID)
|
||||
if err != nil {
|
||||
var record GameRecord
|
||||
row := tx.QueryRowContext(ctx, `SELECT `+gameSelectColumns+` FROM games g WHERE g.game_id = $1`, gameID)
|
||||
if err := scanGame(row, &record); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
moves, err := getMovesForGame(tx, gameID)
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `SELECT game_id, move_number, move_uci,
|
||||
fen_after_move, player_color, move_time_utc
|
||||
FROM moves WHERE game_id = $1 ORDER BY move_number`, gameID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, fmt.Errorf("query game moves: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
moves := make([]MoveRecord, 0)
|
||||
for rows.Next() {
|
||||
var move MoveRecord
|
||||
if err := rows.Scan(
|
||||
&move.GameID, &move.MoveNumber, &move.MoveUCI,
|
||||
&move.FENAfterMove, &move.PlayerColor, &move.MoveTimeUTC,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("scan game move: %w", err)
|
||||
}
|
||||
move.MoveTimeUTC = move.MoveTimeUTC.UTC()
|
||||
moves = append(moves, move)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("iterate game moves: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, nil, fmt.Errorf("finish game history read: %w", err)
|
||||
}
|
||||
slog.Debug("storage game history queried",
|
||||
"game_id", gameID, "move_count", len(moves), "duration", time.Since(started))
|
||||
return record, moves, nil
|
||||
return &record, moves, nil
|
||||
}
|
||||
|
||||
// QueryGamesForUser returns a page of games claimed by userID in either color,
|
||||
// newest first. The last move is read through a LATERAL probe of the moves
|
||||
// primary key, so the summary costs one index lookup per game rather than a
|
||||
// move count over the whole line.
|
||||
func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSummaryRecord, error) {
|
||||
if userID == "" || limit < 1 || limit > 101 || offset < 0 {
|
||||
return nil, errors.New("user ID, limit from 1 to 101, and non-negative offset are required")
|
||||
if userID == "" || limit < 1 || limit > MaxUserGamesPage || offset < 0 {
|
||||
return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required",
|
||||
MaxUserGamesPage)
|
||||
}
|
||||
if !validUUID(userID) {
|
||||
return []GameSummaryRecord{}, nil
|
||||
}
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
query := `SELECT ` + gameSelectColumns + `,
|
||||
(SELECT COUNT(*) FROM moves m WHERE m.game_id = g.game_id) AS move_count
|
||||
const query = `SELECT ` + gameSelectColumns + `,
|
||||
COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen)
|
||||
FROM games g
|
||||
WHERE g.white_player_id = ? OR g.black_player_id = ?
|
||||
OR g.white_claimed_by = ? OR g.black_claimed_by = ?
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT m.move_number, m.fen_after_move FROM moves m
|
||||
WHERE m.game_id = g.game_id
|
||||
ORDER BY m.move_number DESC
|
||||
LIMIT 1
|
||||
) last ON true
|
||||
WHERE g.white_claimed_by = $1 OR g.black_claimed_by = $1
|
||||
ORDER BY g.start_time_utc DESC, g.game_id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
rows, err := s.db.Query(query, userID, userID, userID, userID, limit, offset)
|
||||
LIMIT $2 OFFSET $3`
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user games: %w", err)
|
||||
}
|
||||
@@ -364,7 +378,7 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
|
||||
games := make([]GameSummaryRecord, 0)
|
||||
for rows.Next() {
|
||||
var summary GameSummaryRecord
|
||||
if err := scanGameSummary(rows, &summary); err != nil {
|
||||
if err := scanGame(rows, &summary.GameRecord, &summary.MoveCount, &summary.FinalFEN); err != nil {
|
||||
return nil, fmt.Errorf("scan user game: %w", err)
|
||||
}
|
||||
games = append(games, summary)
|
||||
@@ -386,45 +400,28 @@ type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanGame(scanner rowScanner, record *GameRecord) error {
|
||||
var whiteClaimed, blackClaimed, result sql.NullString
|
||||
// scanGame scans gameSelectColumns followed by any extra destinations.
|
||||
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
var whiteClaimed, whiteName, blackClaimed, blackName, result sql.NullString
|
||||
var endTime sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
dest := []any{
|
||||
&record.GameID, &record.InitialFEN,
|
||||
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, &whiteClaimed,
|
||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed,
|
||||
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime,
|
||||
&whiteClaimed, &whiteName,
|
||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
|
||||
&blackClaimed, &blackName,
|
||||
&result, &record.StartTimeUTC, &endTime,
|
||||
); err != nil {
|
||||
}
|
||||
if err := scanner.Scan(append(dest, extra...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
record.WhiteClaimedBy = whiteClaimed.String
|
||||
record.WhiteName = whiteName.String
|
||||
record.BlackClaimedBy = blackClaimed.String
|
||||
record.BlackName = blackName.String
|
||||
record.Result = result.String
|
||||
if endTime.Valid {
|
||||
ended := endTime.Time
|
||||
record.EndTimeUTC = &ended
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanGameSummary(scanner rowScanner, summary *GameSummaryRecord) error {
|
||||
var whiteClaimed, blackClaimed, result sql.NullString
|
||||
var endTime sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
&summary.GameID, &summary.InitialFEN,
|
||||
&summary.WhitePlayerID, &summary.WhiteType, &summary.WhiteLevel, &summary.WhiteSearchTime, &whiteClaimed,
|
||||
&summary.BlackPlayerID, &summary.BlackType, &summary.BlackLevel, &summary.BlackSearchTime, &blackClaimed,
|
||||
&result, &summary.StartTimeUTC, &endTime, &summary.MoveCount,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.WhiteClaimedBy = whiteClaimed.String
|
||||
summary.BlackClaimedBy = blackClaimed.String
|
||||
summary.Result = result.String
|
||||
if endTime.Valid {
|
||||
ended := endTime.Time
|
||||
summary.EndTimeUTC = &ended
|
||||
}
|
||||
record.StartTimeUTC = record.StartTimeUTC.UTC()
|
||||
record.EndTimeUTC = utcPointer(endTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -439,13 +436,6 @@ func requireOneGame(result sql.Result, gameID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isValidResult(result string) bool {
|
||||
switch result {
|
||||
case "white_wins", "black_wins", "draw", "stalemate":
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package pgtest provisions isolated PostgreSQL schemas for tests.
|
||||
//
|
||||
// Set CHESS_TEST_DSN to a database the test role may create schemas in, e.g.
|
||||
//
|
||||
// CHESS_TEST_DSN='postgres://chess_test:secret@127.0.0.1:5432/chess_test?sslmode=disable'
|
||||
//
|
||||
// Tests that need a database are skipped when it is unset.
|
||||
package pgtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// EnvDSN names the environment variable holding the base test DSN.
|
||||
const EnvDSN = "CHESS_TEST_DSN"
|
||||
|
||||
// DSN creates an empty schema, registers its removal as test cleanup, and
|
||||
// returns the base DSN with search_path pinned to that schema.
|
||||
func DSN(t testing.TB) string {
|
||||
t.Helper()
|
||||
base := os.Getenv(EnvDSN)
|
||||
if base == "" {
|
||||
t.Skipf("%s is not set; skipping PostgreSQL test", EnvDSN)
|
||||
}
|
||||
|
||||
suffix := make([]byte, 6)
|
||||
rand.Read(suffix)
|
||||
schema := "chess_test_" + hex.EncodeToString(suffix)
|
||||
|
||||
admin, err := sql.Open("pgx", base)
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "CREATE SCHEMA "+schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create test schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
defer admin.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
|
||||
t.Errorf("drop test schema %s: %v", schema, err)
|
||||
}
|
||||
})
|
||||
return WithParam(base, "search_path", schema)
|
||||
}
|
||||
|
||||
// WithParam adds a runtime parameter to a URL or keyword/value DSN.
|
||||
func WithParam(dsn, key, value string) string {
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
u, err := url.Parse(dsn)
|
||||
if err == nil {
|
||||
query := u.Query()
|
||||
query.Set(key, value)
|
||||
u.RawQuery = query.Encode()
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
return dsn + " " + key + "=" + value
|
||||
}
|
||||
@@ -2,59 +2,62 @@ package storage
|
||||
|
||||
import "time"
|
||||
|
||||
// UserRecord represents a user account in the database
|
||||
// UserRecord represents a user account in the database. Accounts created by
|
||||
// public registration and by the CLI are identical and do not expire.
|
||||
type UserRecord struct {
|
||||
UserID string `db:"user_id"`
|
||||
Username string `db:"username"`
|
||||
Email string `db:"email"`
|
||||
PasswordHash string `db:"password_hash"`
|
||||
AccountType string `db:"account_type"` // "permanent" or "temp"
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt *time.Time `db:"expires_at"` // nil for permanent
|
||||
LastLoginAt *time.Time `db:"last_login_at"`
|
||||
UserID string
|
||||
Username string
|
||||
Email string // empty when the account has no email (stored as NULL)
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
LastLoginAt *time.Time
|
||||
}
|
||||
|
||||
// SessionRecord represents an active user session
|
||||
type SessionRecord struct {
|
||||
SessionID string `db:"session_id"`
|
||||
UserID string `db:"user_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
SessionID string
|
||||
UserID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// GameRecord represents a row in the games table
|
||||
type GameRecord struct {
|
||||
GameID string `db:"game_id"`
|
||||
InitialFEN string `db:"initial_fen"`
|
||||
WhitePlayerID string `db:"white_player_id"`
|
||||
WhiteType int `db:"white_type"`
|
||||
WhiteLevel int `db:"white_level"`
|
||||
WhiteSearchTime int `db:"white_search_time"`
|
||||
WhiteClaimedBy string `db:"white_claimed_by"`
|
||||
BlackPlayerID string `db:"black_player_id"`
|
||||
BlackType int `db:"black_type"`
|
||||
BlackLevel int `db:"black_level"`
|
||||
BlackSearchTime int `db:"black_search_time"`
|
||||
BlackClaimedBy string `db:"black_claimed_by"`
|
||||
Result string `db:"result"`
|
||||
StartTimeUTC time.Time `db:"start_time_utc"`
|
||||
EndTimeUTC *time.Time `db:"end_time_utc"`
|
||||
GameID string
|
||||
InitialFEN string
|
||||
WhitePlayerID string
|
||||
WhiteType int
|
||||
WhiteLevel int
|
||||
WhiteSearchTime int
|
||||
WhiteClaimedBy string
|
||||
WhiteName string // claimant's username when the claim was recorded
|
||||
BlackPlayerID string
|
||||
BlackType int
|
||||
BlackLevel int
|
||||
BlackSearchTime int
|
||||
BlackClaimedBy string
|
||||
BlackName string
|
||||
Result string
|
||||
StartTimeUTC time.Time
|
||||
EndTimeUTC *time.Time
|
||||
}
|
||||
|
||||
// GameSummaryRecord is a game row plus its replay extent. MoveCount and
|
||||
// FinalFEN come from the last move, so a picker needs no move-list read.
|
||||
type GameSummaryRecord struct {
|
||||
GameRecord
|
||||
MoveCount int `db:"move_count"`
|
||||
MoveCount int
|
||||
FinalFEN string
|
||||
}
|
||||
|
||||
// MoveRecord represents a row in the moves table
|
||||
type MoveRecord struct {
|
||||
MoveID int64 `db:"move_id"`
|
||||
GameID string `db:"game_id"`
|
||||
MoveNumber int `db:"move_number"`
|
||||
MoveUCI string `db:"move_uci"`
|
||||
FENAfterMove string `db:"fen_after_move"`
|
||||
PlayerColor string `db:"player_color"`
|
||||
MoveTimeUTC time.Time `db:"move_time_utc"`
|
||||
GameID string
|
||||
MoveNumber int
|
||||
MoveUCI string
|
||||
FENAfterMove string
|
||||
PlayerColor string
|
||||
MoveTimeUTC time.Time
|
||||
}
|
||||
|
||||
// MovePersistence groups changes caused by one accepted move so the move,
|
||||
@@ -67,72 +70,98 @@ type MovePersistence struct {
|
||||
EndTimeUTC *time.Time
|
||||
}
|
||||
|
||||
// Schema defines tables only. Indexes are applied after legacy column
|
||||
// migrations so upgrading an older games table never references a missing
|
||||
// column.
|
||||
const Schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||
email TEXT COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
account_type TEXT NOT NULL DEFAULT 'temp' CHECK(account_type IN ('permanent', 'temp')),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
last_login_at DATETIME
|
||||
// schemaVersion is the newest schema this binary understands. Migrations are
|
||||
// applied in order inside one transaction; PostgreSQL DDL is transactional, so
|
||||
// a failed upgrade leaves the previous version intact.
|
||||
const schemaVersion = 1
|
||||
|
||||
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
|
||||
// released migration; append a new one.
|
||||
var migrations = []string{
|
||||
// v1: initial PostgreSQL schema.
|
||||
//
|
||||
// Identifiers are uuid (16 bytes, validated on input). Usernames and emails
|
||||
// are stored lowercase, so plain unique constraints give case-insensitive
|
||||
// uniqueness without citext or a nondeterministic collation. Game-to-user
|
||||
// association is by claim: every authenticated human slot records its
|
||||
// claimant, and claims survive player reconfiguration. The claimant's
|
||||
// username is copied into the game when the claim is written, so replays
|
||||
// and PGN keep the name after a rename or account deletion. Games with no
|
||||
// claim belong to anonymous players and are purged after inactivity.
|
||||
`
|
||||
CREATE TABLE schema_version (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
version integer NOT NULL CHECK (version > 0),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
CREATE TABLE users (
|
||||
user_id uuid PRIMARY KEY,
|
||||
username text NOT NULL,
|
||||
email text,
|
||||
password_hash text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_login_at timestamptz,
|
||||
CONSTRAINT users_username_key UNIQUE (username),
|
||||
CONSTRAINT users_email_key UNIQUE (email),
|
||||
CONSTRAINT users_username_check
|
||||
CHECK (username = lower(username) AND char_length(username) BETWEEN 1 AND 64),
|
||||
CONSTRAINT users_email_check
|
||||
CHECK (email = lower(email) AND char_length(email) BETWEEN 3 AND 254)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
game_id TEXT PRIMARY KEY,
|
||||
initial_fen TEXT NOT NULL,
|
||||
white_player_id TEXT NOT NULL,
|
||||
white_type INTEGER NOT NULL,
|
||||
white_level INTEGER NOT NULL DEFAULT 0,
|
||||
white_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
black_player_id TEXT NOT NULL,
|
||||
black_type INTEGER NOT NULL,
|
||||
black_level INTEGER NOT NULL DEFAULT 0,
|
||||
black_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
result TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate')),
|
||||
end_time_utc DATETIME,
|
||||
white_claimed_by TEXT,
|
||||
black_claimed_by TEXT
|
||||
CREATE TABLE sessions (
|
||||
session_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
CONSTRAINT sessions_user_id_key UNIQUE (user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS moves (
|
||||
move_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT NOT NULL,
|
||||
move_number INTEGER NOT NULL,
|
||||
move_uci TEXT NOT NULL,
|
||||
fen_after_move TEXT NOT NULL,
|
||||
player_color TEXT NOT NULL CHECK(player_color IN ('w', 'b')),
|
||||
move_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
UNIQUE(game_id, move_number)
|
||||
CREATE TABLE games (
|
||||
game_id uuid PRIMARY KEY,
|
||||
initial_fen text NOT NULL CHECK (initial_fen <> ''),
|
||||
white_player_id uuid NOT NULL,
|
||||
white_type smallint NOT NULL CHECK (white_type IN (1, 2)),
|
||||
white_level smallint NOT NULL DEFAULT 0 CHECK (white_level BETWEEN 0 AND 20),
|
||||
white_search_time integer NOT NULL DEFAULT 0 CHECK (white_search_time >= 0),
|
||||
white_claimed_by uuid,
|
||||
white_name text CHECK (char_length(white_name) BETWEEN 1 AND 64),
|
||||
black_player_id uuid NOT NULL,
|
||||
black_type smallint NOT NULL CHECK (black_type IN (1, 2)),
|
||||
black_level smallint NOT NULL DEFAULT 0 CHECK (black_level BETWEEN 0 AND 20),
|
||||
black_search_time integer NOT NULL DEFAULT 0 CHECK (black_search_time >= 0),
|
||||
black_claimed_by uuid,
|
||||
black_name text CHECK (char_length(black_name) BETWEEN 1 AND 64),
|
||||
start_time_utc timestamptz NOT NULL DEFAULT now(),
|
||||
result text CHECK (result IN ('white_wins', 'black_wins', 'draw', 'stalemate')),
|
||||
end_time_utc timestamptz,
|
||||
CONSTRAINT games_result_end_time_check CHECK ((result IS NULL) = (end_time_utc IS NULL))
|
||||
);
|
||||
`
|
||||
|
||||
const Indexes = `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
|
||||
ON users(email) WHERE email IS NOT NULL AND email != '';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_temp_created_at
|
||||
ON users(created_at) WHERE account_type = 'temp';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_temp_expires_at
|
||||
ON users(expires_at) WHERE account_type = 'temp' AND expires_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_white_player ON games(white_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_black_player ON games(black_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_white_claimed ON games(white_claimed_by)
|
||||
-- Moves are numbered 1..n without gaps; the primary key serves ordered replay
|
||||
-- reads and the last-move probe used by game listings.
|
||||
CREATE TABLE moves (
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
move_number integer NOT NULL CHECK (move_number > 0),
|
||||
move_uci text NOT NULL CHECK (move_uci ~ '^[a-h][1-8][a-h][1-8][qrbn]?$'),
|
||||
fen_after_move text NOT NULL CHECK (fen_after_move <> ''),
|
||||
player_color text NOT NULL CHECK (player_color IN ('w', 'b')),
|
||||
move_time_utc timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (game_id, move_number)
|
||||
);
|
||||
|
||||
CREATE INDEX sessions_expires_at_idx ON sessions (expires_at);
|
||||
-- Candidates for the anonymous-game purge: a game that started after the
|
||||
-- cutoff cannot have been idle since it.
|
||||
CREATE INDEX games_anonymous_start_idx ON games (start_time_utc)
|
||||
WHERE white_claimed_by IS NULL AND black_claimed_by IS NULL;
|
||||
CREATE INDEX games_white_claimed_idx ON games (white_claimed_by, start_time_utc DESC, game_id DESC)
|
||||
WHERE white_claimed_by IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_games_black_claimed ON games(black_claimed_by)
|
||||
CREATE INDEX games_black_claimed_idx ON games (black_claimed_by, start_time_utc DESC, game_id DESC)
|
||||
WHERE black_claimed_by IS NOT NULL;
|
||||
`
|
||||
`,
|
||||
}
|
||||
|
||||
// ownedTables lists every table created by migrations, children first.
|
||||
var ownedTables = []string{"moves", "games", "sessions", "users", "schema_version"}
|
||||
@@ -1,20 +1,30 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateSession creates or replaces the session for a user (single session per user)
|
||||
// CreateSession creates or replaces the user's single session and records the
|
||||
// login time in the same statement, so a login is one round trip.
|
||||
func (s *Store) CreateSession(record SessionRecord) error {
|
||||
const query = `INSERT INTO sessions (session_id, user_id, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
const query = `WITH session AS (
|
||||
INSERT INTO sessions (session_id, user_id, created_at, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
created_at = excluded.created_at,
|
||||
expires_at = excluded.expires_at`
|
||||
if _, err := s.db.Exec(query, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil {
|
||||
expires_at = excluded.expires_at
|
||||
RETURNING user_id
|
||||
)
|
||||
UPDATE users SET last_login_at = $3 WHERE user_id = (SELECT user_id FROM session)`
|
||||
if _, err := s.db.ExecContext(ctx, query,
|
||||
record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
slog.Debug("storage session created", "user_id", record.UserID, "expires_at", record.ExpiresAt)
|
||||
@@ -23,56 +33,44 @@ func (s *Store) CreateSession(record SessionRecord) error {
|
||||
|
||||
// GetSession retrieves a session by ID
|
||||
func (s *Store) GetSession(sessionID string) (*SessionRecord, error) {
|
||||
if !validUUID(sessionID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
var session SessionRecord
|
||||
query := `SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE session_id = ?`
|
||||
|
||||
err := s.db.QueryRow(query, sessionID).Scan(
|
||||
&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt,
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE session_id = $1`,
|
||||
sessionID,
|
||||
).Scan(&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session.CreatedAt = session.CreatedAt.UTC()
|
||||
session.ExpiresAt = session.ExpiresAt.UTC()
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// GetSessionByUserID retrieves the active session for a user
|
||||
func (s *Store) GetSessionByUserID(userID string) (*SessionRecord, error) {
|
||||
var session SessionRecord
|
||||
query := `SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE user_id = ?`
|
||||
|
||||
err := s.db.QueryRow(query, userID).Scan(
|
||||
&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session
|
||||
// DeleteSession removes a session (logout). Deleting an absent session is not
|
||||
// an error.
|
||||
func (s *Store) DeleteSession(sessionID string) error {
|
||||
query := `DELETE FROM sessions WHERE session_id = ?`
|
||||
_, err := s.db.Exec(query, sessionID)
|
||||
if err == nil {
|
||||
if !validUUID(sessionID) {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE session_id = $1`, sessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("storage session deleted")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSessionByUserID removes all sessions for a user
|
||||
func (s *Store) DeleteSessionByUserID(userID string) error {
|
||||
query := `DELETE FROM sessions WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, userID)
|
||||
if err == nil {
|
||||
slog.Debug("storage user sessions deleted", "user_id", userID)
|
||||
}
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpiredSessions removes expired sessions
|
||||
func (s *Store) DeleteExpiredSessions() (int64, error) {
|
||||
query := `DELETE FROM sessions WHERE expires_at < ?`
|
||||
result, err := s.db.Exec(query, time.Now().UTC())
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < $1`, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -83,29 +81,20 @@ func (s *Store) DeleteExpiredSessions() (int64, error) {
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
// IsSessionValid checks if a session exists and is not expired
|
||||
func (s *Store) IsSessionValid(sessionID string) (bool, error) {
|
||||
var valid bool
|
||||
const query = `SELECT EXISTS(
|
||||
SELECT 1 FROM sessions WHERE session_id = ? AND expires_at > ?
|
||||
)`
|
||||
err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&valid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// IsSessionValidForUser verifies both expiry and the binding between a JWT
|
||||
// subject and its persisted session. Checking only the session ID would allow
|
||||
// a malformed server-issued token to authenticate as the wrong subject.
|
||||
func (s *Store) IsSessionValidForUser(sessionID, userID string) (bool, error) {
|
||||
if !validUUID(sessionID) || !validUUID(userID) {
|
||||
return false, nil
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
var valid bool
|
||||
const query = `SELECT EXISTS(
|
||||
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM sessions
|
||||
WHERE session_id = ? AND user_id = ? AND expires_at > ?
|
||||
)`
|
||||
err := s.db.QueryRow(query, sessionID, userID, time.Now().UTC()).Scan(&valid)
|
||||
WHERE session_id = $1 AND user_id = $2 AND expires_at > $3
|
||||
)`, sessionID, userID, time.Now().UTC()).Scan(&valid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
+248
-165
@@ -3,25 +3,43 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
const (
|
||||
writeQueueCapacity = 1000
|
||||
flushTimeout = 5 * time.Second
|
||||
schemaVersion = 2
|
||||
)
|
||||
queryTimeout = 5 * time.Second
|
||||
writeTimeout = 10 * time.Second
|
||||
migrationTimeout = time.Minute
|
||||
|
||||
var memoryStoreCounter atomic.Uint64
|
||||
// A transient failure (PostgreSQL restart, dropped connection) is retried
|
||||
// before storage degrades. Backoff doubles from writeRetryBase, bounding
|
||||
// the stall of the ordered writer to roughly three seconds.
|
||||
writeAttempts = 6
|
||||
writeRetryBase = 100 * time.Millisecond
|
||||
|
||||
maxOpenConns = 10
|
||||
maxIdleConns = 5
|
||||
|
||||
// schemaLockKey serializes migrations between the server and the CLI.
|
||||
// Advisory locks are scoped to the current database.
|
||||
schemaLockKey int64 = 0x6368657373 // "chess"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStorageDegraded = errors.New("storage is degraded")
|
||||
@@ -32,14 +50,14 @@ var (
|
||||
type writeRequest struct {
|
||||
operation string
|
||||
gameID string
|
||||
run func(*sql.Tx) error
|
||||
run func(context.Context, *sql.Tx) error
|
||||
barrier chan error
|
||||
}
|
||||
|
||||
// Store handles SQLite database operations with async writes for games and sync writes for auth
|
||||
// Store handles PostgreSQL operations with ordered async writes for gameplay
|
||||
// and synchronous writes for accounts and sessions.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
writeChan chan writeRequest
|
||||
healthStatus atomic.Bool
|
||||
writeFailed atomic.Bool
|
||||
@@ -52,68 +70,55 @@ type Store struct {
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// NewStore creates a new storage instance with async writer
|
||||
func NewStore(dataSourceName string, devMode bool) (*Store, error) {
|
||||
dsn := sqliteDSN(dataSourceName)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
// NewStore connects to PostgreSQL and starts the async writer. dsn is a
|
||||
// libpq-style keyword/value string or postgres:// URL; unset fields fall back
|
||||
// to the standard PG* environment variables and ~/.pgpass. The schema is
|
||||
// resolved through search_path, so the role's default or a search_path DSN
|
||||
// parameter selects it.
|
||||
func NewStore(dsn string) (*Store, error) {
|
||||
config, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
// Parse errors may echo the DSN, which can contain a password.
|
||||
return nil, errors.New("invalid database connection string")
|
||||
}
|
||||
if _, ok := config.RuntimeParams["application_name"]; !ok {
|
||||
config.RuntimeParams["application_name"] = "chess-server"
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db := stdlib.OpenDB(*config)
|
||||
db.SetMaxOpenConns(maxOpenConns)
|
||||
db.SetMaxIdleConns(maxIdleConns)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), queryTimeout)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// SQLite benefits from a small pool. WAL and busy_timeout are configured in
|
||||
// the DSN for every connection, unlike connection-local PRAGMA calls.
|
||||
db.SetMaxOpenConns(8)
|
||||
db.SetMaxIdleConns(4)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
writerCtx, writerCancel := context.WithCancel(context.Background())
|
||||
s := &Store{
|
||||
db: db,
|
||||
path: dataSourceName,
|
||||
writeChan: make(chan writeRequest, writeQueueCapacity),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
ctx: writerCtx,
|
||||
cancel: writerCancel,
|
||||
}
|
||||
|
||||
// Initialize health as true
|
||||
s.healthStatus.Store(true)
|
||||
|
||||
// Start async writer
|
||||
s.wg.Add(1)
|
||||
go s.writerLoop()
|
||||
slog.Debug("storage opened",
|
||||
"path", dataSourceName,
|
||||
"dev_mode", devMode,
|
||||
"host", config.Host,
|
||||
"database", config.Database,
|
||||
"user", config.User,
|
||||
"write_queue_capacity", writeQueueCapacity,
|
||||
"max_open_connections", 8,
|
||||
"max_open_connections", maxOpenConns,
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func sqliteDSN(dataSourceName string) string {
|
||||
if dataSourceName == ":memory:" {
|
||||
// Each Store needs a private shared-cache database: shared cache keeps
|
||||
// that Store's pooled connections on one database, while the unique name
|
||||
// prevents independent in-memory stores from leaking into each other.
|
||||
dataSourceName = fmt.Sprintf(
|
||||
"file:chess-memory-%d?mode=memory&cache=shared",
|
||||
memoryStoreCounter.Add(1),
|
||||
)
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(dataSourceName, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return dataSourceName + separator +
|
||||
"_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL"
|
||||
}
|
||||
|
||||
// IsHealthy returns true if the storage is operational
|
||||
func (s *Store) IsHealthy() bool {
|
||||
return s.healthStatus.Load()
|
||||
@@ -176,49 +181,63 @@ func (s *Store) handleWrite(req writeRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// executeWrite runs a transactional write operation
|
||||
// executeWrite runs one logical mutation as one transaction. A transient error
|
||||
// raised before COMMIT is retried: the transaction was rolled back, so a retry
|
||||
// cannot duplicate it, and the single writer preserves ordering while it waits.
|
||||
// A COMMIT error has an unknown outcome and is never retried. Any unrecovered
|
||||
// failure degrades storage permanently, because skipping one write would leave
|
||||
// later writes applied to an incomplete history.
|
||||
func (s *Store) executeWrite(req writeRequest) error {
|
||||
started := time.Now()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: failed to begin transaction",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := req.run(tx); err != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: write operation failed",
|
||||
"operation", req.operation,
|
||||
"game_id", req.gameID,
|
||||
"error", err,
|
||||
"rollback_error", rollbackErr,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: failed to commit",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var err error
|
||||
for attempt := 1; ; attempt++ {
|
||||
var committing bool
|
||||
committing, err = s.runWriteTx(req.run)
|
||||
if err == nil {
|
||||
slog.Debug("storage write committed",
|
||||
"operation", req.operation,
|
||||
"game_id", req.gameID,
|
||||
"attempts", attempt,
|
||||
"duration", time.Since(started),
|
||||
"queue_depth", len(s.writeChan),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if committing || attempt >= writeAttempts || !isTransient(err) {
|
||||
break
|
||||
}
|
||||
backoff := writeRetryBase << (attempt - 1)
|
||||
slog.Warn("storage write failed transiently; retrying",
|
||||
"operation", req.operation, "game_id", req.gameID,
|
||||
"attempt", attempt, "backoff", backoff, "error", err)
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: write operation failed",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) enqueue(operation, gameID string, fn func(*sql.Tx) error) error {
|
||||
func (s *Store) runWriteTx(run func(context.Context, *sql.Tx) error) (committing bool, err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
if err := run(ctx, tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return true, fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Store) enqueue(operation, gameID string, fn func(context.Context, *sql.Tx) error) error {
|
||||
s.enqueueMu.RLock()
|
||||
defer s.enqueueMu.RUnlock()
|
||||
|
||||
@@ -285,7 +304,7 @@ func (s *Store) flushBeforeRead() error {
|
||||
return s.Flush(ctx)
|
||||
}
|
||||
|
||||
// Close gracefully closes the database connection
|
||||
// Close drains accepted writes and closes the connection pool.
|
||||
func (s *Store) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
s.enqueueMu.Lock()
|
||||
@@ -301,125 +320,189 @@ func (s *Store) Close() error {
|
||||
return s.closeErr
|
||||
}
|
||||
|
||||
// InitDB creates the database schema
|
||||
// InitDB applies pending migrations. It holds a transaction-scoped advisory
|
||||
// lock so concurrent migrators serialize, and issues no DDL when the schema is
|
||||
// current: a runtime role limited to DML can start the server once an owner
|
||||
// role has migrated the schema.
|
||||
func (s *Store) InitDB() error {
|
||||
started := time.Now()
|
||||
tx, err := s.db.Begin()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), migrationTimeout)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var currentVersion int
|
||||
if err := tx.QueryRow("PRAGMA user_version").Scan(¤tVersion); err != nil {
|
||||
return fmt.Errorf("failed to read schema version: %w", err)
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, schemaLockKey); err != nil {
|
||||
return fmt.Errorf("failed to lock schema: %w", err)
|
||||
}
|
||||
if currentVersion > schemaVersion {
|
||||
return fmt.Errorf(
|
||||
"database schema version %d is newer than supported version %d",
|
||||
currentVersion,
|
||||
schemaVersion,
|
||||
)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(Schema); err != nil {
|
||||
return fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
columns := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"result", "TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate'))"},
|
||||
{"end_time_utc", "DATETIME"},
|
||||
{"white_claimed_by", "TEXT"},
|
||||
{"black_claimed_by", "TEXT"},
|
||||
}
|
||||
for _, column := range columns {
|
||||
if err := ensureColumn(tx, "games", column.name, column.definition); err != nil {
|
||||
current, err := readSchemaVersion(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current > schemaVersion {
|
||||
return fmt.Errorf("database schema version %d is newer than supported version %d",
|
||||
current, schemaVersion)
|
||||
}
|
||||
if current == schemaVersion {
|
||||
slog.Debug("storage schema current", "version", current)
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// These indexes duplicate UNIQUE constraints or are superseded by targeted
|
||||
// partial/composite indexes. Drop them during upgrades as well as omitting
|
||||
// them from new databases.
|
||||
for _, name := range []string{
|
||||
"idx_users_username",
|
||||
"idx_users_email",
|
||||
"idx_users_account_type",
|
||||
"idx_users_expires_at",
|
||||
"idx_sessions_user_id",
|
||||
"idx_moves_game_id",
|
||||
"idx_games_finished_end_time",
|
||||
} {
|
||||
if _, err := tx.Exec("DROP INDEX IF EXISTS " + name); err != nil {
|
||||
return fmt.Errorf("failed to remove redundant index %s: %w", name, err)
|
||||
for version := current + 1; version <= schemaVersion; version++ {
|
||||
if _, err := tx.ExecContext(ctx, migrations[version-1]); err != nil {
|
||||
return fmt.Errorf("failed to apply schema version %d: %w", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(Indexes); err != nil {
|
||||
return fmt.Errorf("failed to create indexes: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", schemaVersion)); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO schema_version (version) VALUES ($1)
|
||||
ON CONFLICT (singleton) DO UPDATE SET version = excluded.version, updated_at = now()`,
|
||||
schemaVersion,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to record schema version: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit schema: %w", err)
|
||||
}
|
||||
slog.Debug("storage schema ready", "version", schemaVersion, "duration", time.Since(started))
|
||||
slog.Info("storage schema migrated", "from", current, "to", schemaVersion,
|
||||
"duration", time.Since(started))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureColumn(tx *sql.Tx, table, column, definition string) error {
|
||||
rows, err := tx.Query("PRAGMA table_info(" + table + ")")
|
||||
// SchemaVersion reports the version recorded in the database; 0 means no
|
||||
// schema has been created in the current search_path.
|
||||
func (s *Store) SchemaVersion() (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), queryTimeout)
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
return readSchemaVersion(ctx, tx)
|
||||
}
|
||||
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, columnType string
|
||||
var notNull, primaryKey int
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("failed to inspect %s column: %w", table, err)
|
||||
func readSchemaVersion(ctx context.Context, tx *sql.Tx) (int, error) {
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT to_regclass('schema_version') IS NOT NULL`,
|
||||
).Scan(&exists); err != nil {
|
||||
return 0, fmt.Errorf("failed to inspect schema: %w", err)
|
||||
}
|
||||
if name == column {
|
||||
found = true
|
||||
if !exists {
|
||||
return 0, nil
|
||||
}
|
||||
var version int
|
||||
err := tx.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close %s schema rows: %w", table, err)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
|
||||
}
|
||||
if found {
|
||||
return nil
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read schema version: %w", err)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
if _, err := tx.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition); err != nil {
|
||||
return fmt.Errorf("failed to add %s.%s: %w", table, column, err)
|
||||
// DropSchema removes every chess table from the current search_path. It is
|
||||
// destructive and requires ownership of the tables.
|
||||
func (s *Store) DropSchema() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), migrationTimeout)
|
||||
defer cancel()
|
||||
// RESTRICT (the default) refuses to drop objects that others depend on,
|
||||
// such as an administrator's reporting view.
|
||||
_, err := s.db.ExecContext(ctx, `DROP TABLE IF EXISTS `+strings.Join(ownedTables, ", "))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop schema: %w", err)
|
||||
}
|
||||
slog.Debug("storage schema column added", "table", table, "column", column)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDB removes the database file
|
||||
func (s *Store) DeleteDB() error {
|
||||
// Close connection first
|
||||
if err := s.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close database: %w", err)
|
||||
}
|
||||
// opContext bounds a synchronous operation so a stalled database cannot hold a
|
||||
// request handler indefinitely.
|
||||
func opContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), queryTimeout)
|
||||
}
|
||||
|
||||
// ☣ DESTRUCTIVE: Removes database file
|
||||
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to delete database file: %w", err)
|
||||
// isTransient reports errors after which the same transaction may succeed on a
|
||||
// fresh connection.
|
||||
func isTransient(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "40001", // serialization_failure
|
||||
"40P01", // deadlock_detected
|
||||
"57P01", // admin_shutdown
|
||||
"57P02", // crash_shutdown
|
||||
"57P03": // cannot_connect_now
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(pgErr.Code, "08") // connection_exception class
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
var connectErr *pgconn.ConnectError
|
||||
var netErr net.Error
|
||||
return errors.As(err, &connectErr) ||
|
||||
errors.As(err, &netErr) ||
|
||||
errors.Is(err, driver.ErrBadConn) ||
|
||||
errors.Is(err, io.EOF) ||
|
||||
errors.Is(err, io.ErrUnexpectedEOF) ||
|
||||
pgconn.SafeToRetry(err)
|
||||
}
|
||||
|
||||
// uniqueViolation returns the violated constraint name for SQLSTATE 23505.
|
||||
func uniqueViolation(err error) (string, bool) {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return pgErr.ConstraintName, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// validUUID accepts the canonical 36-character form. Lookups treat anything
|
||||
// else as not found instead of sending PostgreSQL a value that fails to cast.
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
_, err := uuid.Parse(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func utcPointer(value sql.NullTime) *time.Time {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
t := value.Time.UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
// ErrSchemaMismatch reports a database whose schema is absent or at a version
|
||||
// other than the one this binary supports.
|
||||
var ErrSchemaMismatch = errors.New("database schema version mismatch")
|
||||
|
||||
// CheckSchema verifies that the schema is current without changing it.
|
||||
func (s *Store) CheckSchema() error {
|
||||
version, err := s.SchemaVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case version == 0:
|
||||
return fmt.Errorf("%w: schema not initialized (run 'chess-server db init')", ErrSchemaMismatch)
|
||||
case version != schemaVersion:
|
||||
return fmt.Errorf("%w: database has version %d, binary supports %d",
|
||||
ErrSchemaMismatch, version, schemaVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+125
-232
@@ -5,76 +5,22 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserAlreadyExists = errors.New("username or email already exists")
|
||||
ErrUserCapacity = errors.New("user capacity reached")
|
||||
ErrPermanentCapacity = errors.New("permanent user capacity reached")
|
||||
)
|
||||
|
||||
// UserLimits defines registration constraints
|
||||
// userCreateLockKey serializes account creation across processes (server and
|
||||
// CLI) so the capacity check cannot be raced past.
|
||||
const userCreateLockKey int64 = 0x6368657375 // "chesu"
|
||||
|
||||
const userSelectColumns = `user_id, username, email, password_hash, created_at, last_login_at`
|
||||
|
||||
// UserLimits defines public-registration constraints.
|
||||
type UserLimits struct {
|
||||
MaxUsers int
|
||||
PermanentSlots int
|
||||
}
|
||||
|
||||
// DefaultUserLimits returns default POC limits
|
||||
func DefaultUserLimits() UserLimits {
|
||||
return UserLimits{
|
||||
MaxUsers: 100,
|
||||
PermanentSlots: 10,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserCounts returns current user counts by type
|
||||
func (s *Store) GetUserCounts() (total, permanent, temp int, err error) {
|
||||
query := `SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END) as permanent,
|
||||
COUNT(CASE WHEN account_type = 'temp' THEN 1 END) as temp
|
||||
FROM users`
|
||||
|
||||
err = s.db.QueryRow(query).Scan(&total, &permanent, &temp)
|
||||
return
|
||||
}
|
||||
|
||||
// GetOldestTempUser returns the oldest temporary user for replacement
|
||||
func (s *Store) GetOldestTempUser() (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users
|
||||
WHERE account_type = 'temp'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`
|
||||
|
||||
err := s.db.QueryRow(query).Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// DeleteExpiredTempUsers removes temporary users past their expiry
|
||||
func (s *Store) DeleteExpiredTempUsers() (int64, error) {
|
||||
query := `DELETE FROM users
|
||||
WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?`
|
||||
result, err := s.db.Exec(query, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err == nil && deleted > 0 {
|
||||
slog.Debug("storage expired temporary users deleted", "count", deleted)
|
||||
}
|
||||
return deleted, err
|
||||
MaxUsers int // total accounts at which registration closes
|
||||
}
|
||||
|
||||
// CreateUser creates an administratively managed user without applying the
|
||||
@@ -83,10 +29,9 @@ func (s *Store) CreateUser(record UserRecord) error {
|
||||
return s.createUser(record, nil, nil)
|
||||
}
|
||||
|
||||
// CreateUserWithinLimits atomically applies registration limits, evicts the
|
||||
// oldest temporary account when required, creates the user, and optionally
|
||||
// creates its initial session. No account is evicted on a duplicate request,
|
||||
// and a session failure rolls back the user and eviction together.
|
||||
// CreateUserWithinLimits atomically applies the registration limit, creates
|
||||
// the user, and optionally creates its initial session. A session failure
|
||||
// rolls back the account.
|
||||
func (s *Store) CreateUserWithinLimits(
|
||||
record UserRecord,
|
||||
session *SessionRecord,
|
||||
@@ -96,69 +41,55 @@ func (s *Store) CreateUserWithinLimits(
|
||||
}
|
||||
|
||||
func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *UserLimits) error {
|
||||
tx, err := s.db.Begin()
|
||||
if session != nil && session.UserID != record.UserID {
|
||||
return errors.New("initial session user does not match new user")
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Check uniqueness within transaction
|
||||
exists, err := s.userExists(tx, record.Username, record.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, userCreateLockKey); err != nil {
|
||||
return fmt.Errorf("lock user creation: %w", err)
|
||||
}
|
||||
|
||||
// Report a duplicate as such even when registration is full. The advisory
|
||||
// lock orders this check with concurrent creations; the unique constraints
|
||||
// still catch a race with renames.
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM users WHERE username = lower($1) OR email = lower($2)
|
||||
)`, record.Username, nullableString(record.Email)).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check user uniqueness: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
if limits != nil {
|
||||
var total, permanent int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*),
|
||||
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END)
|
||||
FROM users`).Scan(&total, &permanent); err != nil {
|
||||
if limits != nil && limits.MaxUsers > 0 {
|
||||
var total int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&total); err != nil {
|
||||
return fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
if record.AccountType == "permanent" && permanent >= limits.PermanentSlots {
|
||||
return ErrPermanentCapacity
|
||||
}
|
||||
if total >= limits.MaxUsers {
|
||||
result, err := tx.Exec(`DELETE FROM users WHERE user_id = (
|
||||
SELECT user_id FROM users
|
||||
WHERE account_type = 'temp'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evict oldest temporary user: %w", err)
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect temporary user eviction: %w", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
return ErrUserCapacity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert user
|
||||
query := `INSERT INTO users (
|
||||
user_id, username, email, password_hash, account_type, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err = tx.Exec(query,
|
||||
record.UserID, record.Username, record.Email,
|
||||
record.PasswordHash, record.AccountType, record.CreatedAt, record.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO users (
|
||||
user_id, username, email, password_hash, created_at
|
||||
) VALUES ($1, lower($2), lower($3), $4, $5)`,
|
||||
record.UserID, record.Username, nullableString(record.Email),
|
||||
record.PasswordHash, record.CreatedAt,
|
||||
); err != nil {
|
||||
return mapUserConflict(err)
|
||||
}
|
||||
if session != nil {
|
||||
if session.UserID != record.UserID {
|
||||
return errors.New("initial session user does not match new user")
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES ($1, $2, $3, $4)`,
|
||||
session.SessionID, session.UserID, session.CreatedAt, session.ExpiresAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create initial session: %w", err)
|
||||
@@ -168,78 +99,74 @@ func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *Us
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("storage user created",
|
||||
"user_id", record.UserID,
|
||||
"account_type", record.AccountType,
|
||||
"initial_session", session != nil,
|
||||
)
|
||||
slog.Debug("storage user created", "user_id", record.UserID, "initial_session", session != nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUserByID removes a user by ID (synchronous, for replacement logic)
|
||||
func (s *Store) DeleteUserByID(userID string) error {
|
||||
query := `DELETE FROM users WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, userID)
|
||||
if err == nil {
|
||||
// mapUserConflict converts a username/email uniqueness violation, including one
|
||||
// lost to a concurrent writer, into ErrUserAlreadyExists.
|
||||
func mapUserConflict(err error) error {
|
||||
if constraint, ok := uniqueViolation(err); ok &&
|
||||
(constraint == "users_username_key" || constraint == "users_email_key") {
|
||||
return ErrUserAlreadyExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUser removes a user synchronously; sessions cascade, while games keep
|
||||
// the claim ID and name snapshot. It returns sql.ErrNoRows when no such user
|
||||
// exists.
|
||||
func (s *Store) DeleteUser(userID string) error {
|
||||
if err := s.execUserUpdate(`DELETE FROM users WHERE user_id = $1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("storage user deleted", "user_id", userID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// PromoteToPermament upgrades a temp user to permanent
|
||||
func (s *Store) PromoteToPermanent(userID string) error {
|
||||
query := `UPDATE users SET account_type = 'permanent', expires_at = NULL WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// userExists verifies username/email uniqueness within a transaction
|
||||
func (s *Store) userExists(tx *sql.Tx, username, email string) (bool, error) {
|
||||
var count int
|
||||
query := `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE`
|
||||
args := []any{username}
|
||||
|
||||
if email != "" {
|
||||
query = `SELECT COUNT(*) FROM users
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
OR (email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '')`
|
||||
args = append(args, email)
|
||||
}
|
||||
|
||||
err := tx.QueryRow(query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserPassword updates user password hash
|
||||
func (s *Store) UpdateUserPassword(userID string, passwordHash string) error {
|
||||
query := `UPDATE users SET password_hash = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, passwordHash, userID)
|
||||
return err
|
||||
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
||||
return s.execUserUpdate(`UPDATE users SET password_hash = $2 WHERE user_id = $1`,
|
||||
userID, passwordHash)
|
||||
}
|
||||
|
||||
// UpdateUserEmail updates user email
|
||||
func (s *Store) UpdateUserEmail(userID string, email string) error {
|
||||
query := `UPDATE users SET email = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, email, userID)
|
||||
return err
|
||||
// UpdateUserEmail updates the email; an empty value removes it.
|
||||
func (s *Store) UpdateUserEmail(userID, email string) error {
|
||||
return s.execUserUpdate(`UPDATE users SET email = lower($2) WHERE user_id = $1`,
|
||||
userID, nullableString(email))
|
||||
}
|
||||
|
||||
// UpdateUserUsername updates username
|
||||
func (s *Store) UpdateUserUsername(userID string, username string) error {
|
||||
query := `UPDATE users SET username = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, username, userID)
|
||||
return err
|
||||
func (s *Store) UpdateUserUsername(userID, username string) error {
|
||||
return s.execUserUpdate(`UPDATE users SET username = lower($2) WHERE user_id = $1`,
|
||||
userID, username)
|
||||
}
|
||||
|
||||
// GetAllUsers retrieves all users
|
||||
func (s *Store) GetAllUsers() ([]UserRecord, error) {
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users ORDER BY created_at DESC`
|
||||
// execUserUpdate runs a single-row statement keyed by user_id ($1).
|
||||
func (s *Store) execUserUpdate(query, userID string, args ...any) error {
|
||||
if !validUUID(userID) {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
result, err := s.db.ExecContext(ctx, query, append([]any{userID}, args...)...)
|
||||
if err != nil {
|
||||
return mapUserConflict(err)
|
||||
}
|
||||
if rows, err := result.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query)
|
||||
// GetAllUsers retrieves all users, newest first
|
||||
func (s *Store) GetAllUsers() ([]UserRecord, error) {
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+userSelectColumns+` FROM users ORDER BY created_at DESC, user_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -248,91 +175,57 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
|
||||
var users []UserRecord
|
||||
for rows.Next() {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
err := rows.Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err := scanUser(rows, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateUserLastLoginSync updates user last login time
|
||||
func (s *Store) UpdateUserLastLoginSync(userID string, loginTime time.Time) error {
|
||||
query := `UPDATE users SET last_login_at = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, loginTime, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update last login for user %s: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByUsername retrieves user by username with case-insensitive matching
|
||||
// GetUserByUsername retrieves a user by case-insensitive username.
|
||||
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users WHERE username = ? COLLATE NOCASE`
|
||||
|
||||
err := s.db.QueryRow(query, username).Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
return &user, nil
|
||||
return s.getUser(`username = lower($1)`, username)
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves user by email with case-insensitive matching
|
||||
// GetUserByEmail retrieves a user by case-insensitive email.
|
||||
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var emailNull sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''`
|
||||
|
||||
err := s.db.QueryRow(query, email).Scan(
|
||||
&user.UserID, &user.Username, &emailNull,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if email == "" {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
user.Email = emailNull.String
|
||||
return &user, nil
|
||||
return s.getUser(`email = lower($1)`, email)
|
||||
}
|
||||
|
||||
// GetUserByID retrieves user by unique user ID
|
||||
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users WHERE user_id = ?`
|
||||
if !validUUID(userID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return s.getUser(`user_id = $1`, userID)
|
||||
}
|
||||
|
||||
err := s.db.QueryRow(query, userID).Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
func (s *Store) getUser(predicate string, arg any) (*UserRecord, error) {
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
var user UserRecord
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+userSelectColumns+` FROM users WHERE `+predicate, arg)
|
||||
if err := scanUser(row, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user synchronously. Account operations are consistency
|
||||
// sensitive and should not be reported successful before SQLite commits them.
|
||||
func (s *Store) DeleteUser(userID string) error {
|
||||
return s.DeleteUserByID(userID)
|
||||
func scanUser(scanner rowScanner, user *UserRecord) error {
|
||||
var email sql.NullString
|
||||
var lastLoginAt sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.CreatedAt, &lastLoginAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
user.Email = email.String
|
||||
user.CreatedAt = user.CreatedAt.UTC()
|
||||
user.LastLoginAt = utcPointer(lastLoginAt)
|
||||
return nil
|
||||
}
|
||||
+26
-9
@@ -6,9 +6,26 @@ This directory contains comprehensive test suites for the Chess API server, cove
|
||||
|
||||
- `jq` - JSON processor
|
||||
- `curl` - HTTP client
|
||||
- `sqlite3` - SQLite CLI (for database tests)
|
||||
- `psql` - PostgreSQL client (for database tests)
|
||||
- `base64` - Base64 encoder (for JWT tests)
|
||||
- Stockfish in `PATH`
|
||||
- Compiled `bin/chess-server` binary (or pass another path to the scripts)
|
||||
- A **disposable** PostgreSQL database named by `CHESS_TEST_DSN`
|
||||
|
||||
`CHESS_TEST_DSN` must be a libpq-compatible connection string, because the
|
||||
scripts pass it to both `chess-server` and `psql`. The test server drops every
|
||||
chess table in that database on start and exit, so never point it at real data.
|
||||
|
||||
```bash
|
||||
# One-time setup as a PostgreSQL superuser
|
||||
psql -U postgres -c "CREATE ROLE chess_test LOGIN PASSWORD 'chess_test'"
|
||||
psql -U postgres -c "CREATE DATABASE chess_test OWNER chess_test"
|
||||
|
||||
export CHESS_TEST_DSN='postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable'
|
||||
```
|
||||
|
||||
The same variable enables the Go database tests (`go test ./...`), which create
|
||||
and drop an isolated schema per test and are skipped when it is unset.
|
||||
|
||||
## Running the test server
|
||||
From repo root
|
||||
@@ -17,8 +34,8 @@ test/run-test-server.sh
|
||||
```
|
||||
|
||||
Pass the binary path as the first argument when it is not at `bin/chess-server`.
|
||||
The server runs with `-dev`, debug logging, HTTP request logging, and relaxed rate limiting. WAL is enabled for every persistent server mode.
|
||||
Will clean up test database and temporary files, so it's preferred for clean testing.
|
||||
The server runs with `-dev`, debug logging, HTTP request logging, and relaxed rate limiting.
|
||||
It drops the test tables on exit, so it's preferred for clean testing.
|
||||
Can be used for all the tests.
|
||||
|
||||
Logging can be adjusted without editing the script:
|
||||
@@ -63,7 +80,7 @@ curl -X POST http://localhost:8080/games \
|
||||
| API Functionality | `test-api.sh` | Game operations, moves, undo, rate limiting |
|
||||
| Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence |
|
||||
| Long-Polling | `test-longpoll.sh` | Real-time updates, wait behavior, timeouts |
|
||||
| Test Server | `test-db-server.sh` | Pre-populated test environment |
|
||||
| Test Server | `run-test-server.sh` | Pre-populated test environment |
|
||||
|
||||
## 1. API Functionality Tests (`test-api.sh`)
|
||||
|
||||
@@ -97,8 +114,8 @@ Tests user management, authentication, and persistence via API integration.
|
||||
### Running the test
|
||||
```bash
|
||||
# Terminal 1: Start test server with database
|
||||
# Server is running with -dev and persistent WAL storage
|
||||
test/test-db-server.sh bin/chess-server
|
||||
# Server is running with -dev and PostgreSQL storage
|
||||
test/run-test-server.sh bin/chess-server
|
||||
|
||||
# Terminal 2: Run API integration tests
|
||||
test/test-db.sh bin/chess-server
|
||||
@@ -114,12 +131,12 @@ test/test-db.sh bin/chess-server
|
||||
- **Database Schema**: Table creation, constraints, indexes
|
||||
|
||||
### Test Flow
|
||||
1. Creates temporary `test.db` with schema
|
||||
1. Re-runs the idempotent schema initialization against `CHESS_TEST_DSN`
|
||||
2. Registers test users (alice, bob, charlie)
|
||||
3. Tests authentication endpoints
|
||||
4. Validates JWT tokens and claims
|
||||
5. Tests duplicate user prevention
|
||||
6. Cleans up test database
|
||||
6. Leaves cleanup (dropping tables) to `run-test-server.sh`
|
||||
|
||||
## 3. Long-Polling Tests (`test-longpoll.sh`)
|
||||
|
||||
@@ -129,7 +146,7 @@ Tests real-time game updates via HTTP long-polling.
|
||||
```bash
|
||||
# Terminal 1: Start server with storage
|
||||
test/run-test-server.sh bin/chess-server
|
||||
# Direct (test.db cleanup required): bin/chess-server -dev -storage-path test.db
|
||||
# Direct (table cleanup required): CHESS_DSN="$CHESS_TEST_DSN" bin/chess-server -dev
|
||||
|
||||
# Terminal 2: Run long-polling tests
|
||||
test/test-longpoll.sh
|
||||
|
||||
+15
-12
@@ -4,7 +4,10 @@ set -e
|
||||
|
||||
# Configuration
|
||||
CHESS_SERVER_EXEC=${1:-"bin/chess-server"}
|
||||
TEST_DB="test.db"
|
||||
# A DISPOSABLE database: every chess table in it is dropped on start and exit.
|
||||
# Use a libpq-compatible DSN; test-db.sh also passes it to psql.
|
||||
: "${CHESS_TEST_DSN:?set CHESS_TEST_DSN to a disposable PostgreSQL database}"
|
||||
export CHESS_DSN="$CHESS_TEST_DSN"
|
||||
PID_FILE="/tmp/chess-server_test.pid"
|
||||
API_PORT=${API_PORT:-8080}
|
||||
LOG_LEVEL=${LOG_LEVEL:-debug}
|
||||
@@ -41,9 +44,9 @@ cleanup() {
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
# Clean up database files
|
||||
echo "Removing test database files..."
|
||||
rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm"
|
||||
# Drop test tables
|
||||
echo "Dropping test database tables..."
|
||||
"$CHESS_SERVER_EXEC" db delete -confirm >/dev/null || true
|
||||
|
||||
echo -e "${GREEN}Cleanup complete${NC}"
|
||||
}
|
||||
@@ -51,13 +54,14 @@ cleanup() {
|
||||
# Set up trap for cleanup on exit
|
||||
trap cleanup EXIT SIGINT SIGTERM
|
||||
|
||||
# Clean slate - remove any existing test DB files
|
||||
# Clean slate - drop tables left by an earlier run
|
||||
echo -e "${CYAN}Preparing test environment...${NC}"
|
||||
rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm" "$PID_FILE"
|
||||
rm -f "$PID_FILE"
|
||||
"$CHESS_SERVER_EXEC" db delete -confirm
|
||||
|
||||
# Initialize database
|
||||
echo -e "${CYAN}Initializing test database...${NC}"
|
||||
"$CHESS_SERVER_EXEC" db init -path "$TEST_DB"
|
||||
"$CHESS_SERVER_EXEC" db init
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}Failed to initialize database${NC}"
|
||||
exit 1
|
||||
@@ -65,14 +69,14 @@ fi
|
||||
|
||||
# Add test users
|
||||
echo -e "${CYAN}Adding test users...${NC}"
|
||||
"$CHESS_SERVER_EXEC" db user add -path "$TEST_DB" \
|
||||
"$CHESS_SERVER_EXEC" db user add \
|
||||
-username alice -email alice@test.com -password AlicePass123
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}Failed to create user alice${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$CHESS_SERVER_EXEC" db user add -path "$TEST_DB" \
|
||||
"$CHESS_SERVER_EXEC" db user add \
|
||||
-username bob -email bob@test.com -password BobSecure456
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}Failed to create user bob${NC}"
|
||||
@@ -90,9 +94,9 @@ echo -e "${CYAN}╚════════════════════
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " Executable: $CHESS_SERVER_EXEC"
|
||||
echo " Database: $TEST_DB"
|
||||
echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)"
|
||||
echo " Port: $API_PORT"
|
||||
echo " Mode: Development (relaxed rate limits; persistent WAL storage)"
|
||||
echo " Mode: Development (relaxed rate limits; PostgreSQL storage)"
|
||||
echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
|
||||
echo " Purpose: Backend for chess-server tests"
|
||||
echo " PID File: $PID_FILE"
|
||||
@@ -111,7 +115,6 @@ echo ""
|
||||
-dev \
|
||||
-log-level "$LOG_LEVEL" \
|
||||
-log-http="$LOG_HTTP" \
|
||||
-storage-path "$TEST_DB" \
|
||||
-api-port "$API_PORT" \
|
||||
-pid "$PID_FILE" \
|
||||
-pid-lock
|
||||
+21
-18
@@ -8,8 +8,11 @@
|
||||
|
||||
BASE_URL="http://localhost:8080"
|
||||
API_URL="${BASE_URL}/api/v1"
|
||||
TEST_DB="test.db"
|
||||
CHESS_SERVER_EXEC=${1:-"bin/chess-server"}
|
||||
# Same disposable database as the running test server (see run-test-server.sh).
|
||||
# Use a libpq-compatible DSN: it is passed to both chess-server and psql.
|
||||
: "${CHESS_TEST_DSN:?set CHESS_TEST_DSN to the test server database}"
|
||||
export CHESS_DSN="$CHESS_TEST_DSN"
|
||||
API_DELAY=${API_DELAY:-50}
|
||||
|
||||
# Colors
|
||||
@@ -121,7 +124,7 @@ api_request() {
|
||||
}
|
||||
|
||||
# Check dependencies
|
||||
for cmd in jq sqlite3 curl; do
|
||||
for cmd in jq psql curl; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
echo -e "${RED}Error: $cmd is required but not installed${NC}"
|
||||
exit 1
|
||||
@@ -145,7 +148,7 @@ fi
|
||||
print_header "Database & User Management Test Suite"
|
||||
echo "Server: $BASE_URL"
|
||||
echo "Executable: $CHESS_SERVER_EXEC"
|
||||
echo "Test Database (server-managed): $TEST_DB"
|
||||
echo "Test Database: \$CHESS_TEST_DSN (server-managed)"
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
@@ -153,11 +156,11 @@ print_header "SECTION 1: CLI User Operations"
|
||||
# ==============================================================================
|
||||
|
||||
test_case "1.1: database initialization"
|
||||
assert_command "$CHESS_SERVER_EXEC db init -path $TEST_DB" 0 "initialize database"
|
||||
assert_command "$CHESS_SERVER_EXEC db init" 0 "initialize database"
|
||||
|
||||
# Create testuser1 first (not charlie)
|
||||
test_case "1.2: Add First User via CLI"
|
||||
OUTPUT=$($CHESS_SERVER_EXEC db user add -path "$TEST_DB" -username "testuser1" \
|
||||
OUTPUT=$($CHESS_SERVER_EXEC db user add -username "testuser1" \
|
||||
-email "testuser1@test.com" -password "TestPass123" 2>&1)
|
||||
if echo "$OUTPUT" | grep -qi "User created successfully"; then
|
||||
echo -e "${GREEN} ✓ User created: testuser1${NC}"
|
||||
@@ -168,7 +171,7 @@ else
|
||||
fi
|
||||
|
||||
test_case "1.3: Add Second User"
|
||||
OUTPUT=$($CHESS_SERVER_EXEC db user add -path "$TEST_DB" -username "testuser2" \
|
||||
OUTPUT=$($CHESS_SERVER_EXEC db user add -username "testuser2" \
|
||||
-password "TestPass456" 2>&1)
|
||||
if echo "$OUTPUT" | grep -qi "User created successfully"; then
|
||||
echo -e "${GREEN} ✓ User created: testuser2${NC}"
|
||||
@@ -180,7 +183,7 @@ fi
|
||||
|
||||
# Now test duplicate prevention with an existing user
|
||||
test_case "1.4: Duplicate Username Prevention"
|
||||
assert_command "$CHESS_SERVER_EXEC db user add -path $TEST_DB -username testuser1 -password TestPass789" 1 \
|
||||
assert_command "$CHESS_SERVER_EXEC db user add -username testuser1 -password TestPass789" 1 \
|
||||
"Duplicate username rejected"
|
||||
|
||||
test_case "1.5: Login with Case-Insensitive Username (ALICE)"
|
||||
@@ -196,11 +199,11 @@ else
|
||||
fi
|
||||
|
||||
test_case "1.6: Update User Email"
|
||||
assert_command "$CHESS_SERVER_EXEC db user set-email -path $TEST_DB -username testuser2 -email testuser2_updated@test.com" 0 \
|
||||
assert_command "$CHESS_SERVER_EXEC db user set-email -username testuser2 -email testuser2_updated@test.com" 0 \
|
||||
"Email update"
|
||||
|
||||
test_case "1.7: Update User Password"
|
||||
assert_command "$CHESS_SERVER_EXEC db user set-password -path $TEST_DB -username testuser2 -password NewPass789" 0 \
|
||||
assert_command "$CHESS_SERVER_EXEC db user set-password -username testuser2 -password NewPass789" 0 \
|
||||
"Password update"
|
||||
|
||||
test_case "2.1: Health Check"
|
||||
@@ -324,7 +327,7 @@ test_case "4.1: Verify Game Storage with User ID"
|
||||
if [ -n "$GAME_ID" ]; then
|
||||
sleep 0.5 # Allow async write
|
||||
|
||||
DB_WHITE_ID=$(sqlite3 "$TEST_DB" "SELECT white_player_id FROM games WHERE game_id = '$GAME_ID';" 2>/dev/null)
|
||||
DB_WHITE_ID=$(psql "$CHESS_TEST_DSN" -XAtqc "SELECT white_player_id FROM games WHERE game_id = '$GAME_ID';" 2>/dev/null)
|
||||
if [ "$DB_WHITE_ID" = "$USER_ID_ALICE" ]; then
|
||||
echo -e "${GREEN} ✓ User ID correctly persisted in database${NC}"
|
||||
((PASS++))
|
||||
@@ -335,7 +338,7 @@ if [ -n "$GAME_ID" ]; then
|
||||
fi
|
||||
|
||||
test_case "4.2: Query Games by User ID"
|
||||
GAMES_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM games WHERE white_player_id = '$USER_ID_ALICE' OR black_player_id = '$USER_ID_ALICE';" 2>/dev/null)
|
||||
GAMES_COUNT=$(psql "$CHESS_TEST_DSN" -XAtqc "SELECT COUNT(*) FROM games WHERE white_player_id = '$USER_ID_ALICE' OR black_player_id = '$USER_ID_ALICE';" 2>/dev/null)
|
||||
if [ "$GAMES_COUNT" -ge "2" ]; then
|
||||
echo -e "${GREEN} ✓ User's games queryable: found $GAMES_COUNT games${NC}"
|
||||
((PASS++))
|
||||
@@ -350,7 +353,7 @@ print_header "SECTION 5: Password Operations"
|
||||
|
||||
# Now TEST_PASS2_NEW is defined, this test should work
|
||||
test_case "5.1: Update User Password via CLI for 'bob'"
|
||||
assert_command "$CHESS_SERVER_EXEC db user set-password -path $TEST_DB -username $TEST_USER2 -password $TEST_PASS2_NEW" 0 \
|
||||
assert_command "$CHESS_SERVER_EXEC db user set-password -username $TEST_USER2 -password $TEST_PASS2_NEW" 0 \
|
||||
"CLI password update for '$TEST_USER2'"
|
||||
|
||||
test_case "5.2: Login with NEW Password for 'bob'"
|
||||
@@ -373,11 +376,11 @@ STATUS=$(api_request POST "$API_URL/auth/login" \
|
||||
assert_status 401 "$STATUS" "Old password correctly rejected for '$TEST_USER2'"
|
||||
|
||||
test_case "5.4: Add new user '$TEST_USER_CLI' via CLI for hash test"
|
||||
assert_command "$CHESS_SERVER_EXEC db user add -path $TEST_DB -username $TEST_USER_CLI -password $TEST_PASS_CLI" 0 \
|
||||
assert_command "$CHESS_SERVER_EXEC db user add -username $TEST_USER_CLI -password $TEST_PASS_CLI" 0 \
|
||||
"Add user '$TEST_USER_CLI' for hash test"
|
||||
|
||||
test_case "5.5: CLI rejects unsupported hash format"
|
||||
assert_command "$CHESS_SERVER_EXEC db user set-hash -path $TEST_DB -username $TEST_USER_CLI -hash '$UNSUPPORTED_HASH'" 1 \
|
||||
assert_command "$CHESS_SERVER_EXEC db user set-hash -username $TEST_USER_CLI -hash '$UNSUPPORTED_HASH'" 1 \
|
||||
"Unsupported bcrypt hash rejected by CLI"
|
||||
|
||||
# ==============================================================================
|
||||
@@ -409,7 +412,7 @@ test_case "6.2: Concurrent Registration Handling"
|
||||
wait
|
||||
|
||||
# Check only one user was created
|
||||
USER_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM users WHERE username = 'concurrent';" 2>/dev/null)
|
||||
USER_COUNT=$(psql "$CHESS_TEST_DSN" -XAtqc "SELECT COUNT(*) FROM users WHERE username = 'concurrent';" 2>/dev/null)
|
||||
if [ "$USER_COUNT" = "1" ]; then
|
||||
echo -e "${GREEN} ✓ Concurrent registration handled correctly${NC}"
|
||||
((PASS++))
|
||||
@@ -420,15 +423,15 @@ fi
|
||||
|
||||
test_case "6.3: Delete User"
|
||||
# First, get a user to delete
|
||||
OUTPUT=$($CHESS_SERVER_EXEC db user add -path "$TEST_DB" -username "deleteme" \
|
||||
OUTPUT=$($CHESS_SERVER_EXEC db user add -username "deleteme" \
|
||||
-password "TempPass123" 2>&1)
|
||||
TEMP_ID=$(echo "$OUTPUT" | grep "ID:" | awk '{print $2}')
|
||||
|
||||
assert_command "$CHESS_SERVER_EXEC db user delete -path $TEST_DB -username deleteme" 0 \
|
||||
assert_command "$CHESS_SERVER_EXEC db user delete -username deleteme" 0 \
|
||||
"User deletion by username"
|
||||
|
||||
# Verify deletion
|
||||
USER_EXISTS=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM users WHERE user_id = '$TEMP_ID';" 2>/dev/null)
|
||||
USER_EXISTS=$(psql "$CHESS_TEST_DSN" -XAtqc "SELECT COUNT(*) FROM users WHERE user_id = '$TEMP_ID';" 2>/dev/null)
|
||||
if [ "$USER_EXISTS" = "0" ]; then
|
||||
echo -e "${GREEN} ✓ User successfully deleted from database${NC}"
|
||||
((PASS++))
|
||||
|
||||
Reference in new issue
Block a user