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

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

No files matched your search

+43
View File
@@ -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
+45 -36
View File
@@ -10,8 +10,13 @@ GO := go
GOROOT := $(shell go env GOROOT) GOROOT := $(shell go env GOROOT)
GOFLAGS := -trimpath GOFLAGS := -trimpath
LDFLAGS := -s -w LDFLAGS := -s -w
CGO_SERVER := CGO_ENABLED=1 # Both binaries are pure Go (pgx has no C dependency), so builds are static and
CGO_CLIENT := CGO_ENABLED=0 # 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 # Build info
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
@@ -40,22 +45,17 @@ all: build
.PHONY: build .PHONY: build
build: server client 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 .PHONY: server
server: $(SERVER_BINARY) server: | $(BINARY_DIR)
$(CGO) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE)
# Build server — CGO required for go-sqlite3
$(SERVER_BINARY): $(BINARY_DIR)
$(CGO_SERVER) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE)
@echo "Built server: $(SERVER_BINARY)" @echo "Built server: $(SERVER_BINARY)"
# Build client only # Build client only
.PHONY: client .PHONY: client
client: $(CLIENT_BINARY) client: | $(BINARY_DIR)
$(CGO) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
# Build client — pure Go, no CGO
$(CLIENT_BINARY): $(BINARY_DIR)
$(CGO_CLIENT) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
@echo "Built client: $(CLIENT_BINARY)" @echo "Built client: $(CLIENT_BINARY)"
# Create bin directory # Create bin directory
@@ -66,7 +66,7 @@ $(BINARY_DIR):
.PHONY: wasm .PHONY: wasm
wasm: $(WASM_DIR) wasm: $(WASM_DIR)
@echo "Building WASM client..." @echo "Building WASM client..."
$(CGO_CLIENT) GOOS=js GOARCH=wasm $(GO) build $(GOFLAGS) \ $(CGO) GOOS=js GOARCH=wasm $(GO) build $(GOFLAGS) \
-ldflags "$(LDFLAGS)" \ -ldflags "$(LDFLAGS)" \
-o $(WASM_BINARY) $(CLIENT_SOURCE) -o $(WASM_BINARY) $(CLIENT_SOURCE)
@cp "$(WASM_EXEC_SRC)" $(WASM_DIR)/ @cp "$(WASM_EXEC_SRC)" $(WASM_DIR)/
@@ -112,21 +112,28 @@ $(WASM_DIR):
# Run server with default settings # Run server with default settings
.PHONY: run-server .PHONY: run-server
run-server: 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 # Run server with web UI
.PHONY: run-server-web .PHONY: run-server-web
run-server-web: server 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 # Run client
.PHONY: run-client .PHONY: run-client
run-client: client run-client: client
$(CLIENT_BINARY) $(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 .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 test/run-test-server.sh
# Run individual test suites # Run individual test suites
@@ -145,25 +152,25 @@ test-longpoll:
# Database operations # Database operations
.PHONY: db-init .PHONY: db-init
db-init: server db-init: server
$(SERVER_BINARY) db init -path db/chess.db CHESS_DSN='$(CHESS_DSN)' $(SERVER_BINARY) db init
.PHONY: db-clean .PHONY: db-clean
db-clean: db-clean: server
# ☣ DESTRUCTIVE: Removes database # ☣ DESTRUCTIVE: drops every chess table in the DSN's search_path
rm -f db/chess.db db/chess.db-* CHESS_DSN='$(CHESS_DSN)' $(SERVER_BINARY) db delete -confirm
# Cross-compile server for FreeBSD from Linux (requires zig or freebsd cross toolchain) # Cross-compile a static server for FreeBSD/amd64 (no C toolchain required)
# Usage: make server-freebsd-cross CC="zig cc -target x86_64-freebsd" .PHONY: server-freebsd
.PHONY: server-freebsd-cross server-freebsd: | $(BINARY_DIR)
server-freebsd-cross: $(BINARY_DIR) $(CGO) GOOS=freebsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" \
$(CGO_SERVER) GOOS=freebsd GOARCH=amd64 CC="$(CC)" \ $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE) -o $(SERVER_BINARY)-freebsd-amd64 $(SERVER_SOURCE)
@echo "Built FreeBSD server (cross): $(SERVER_BINARY)" @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 .PHONY: dev
dev: dev: | $(BINARY_DIR)
$(CGO_SERVER) $(GO) build -race -o $(SERVER_BINARY) $(SERVER_SOURCE) CGO_ENABLED=1 $(GO) build -race -o $(SERVER_BINARY) $(SERVER_SOURCE)
$(CGO_CLIENT) $(GO) build -race -o $(CLIENT_BINARY) $(CLIENT_SOURCE) CGO_ENABLED=1 $(GO) build -race -o $(CLIENT_BINARY) $(CLIENT_SOURCE)
@echo "Built with race detector enabled" @echo "Built with race detector enabled"
# Clean build artifacts # Clean build artifacts
@@ -206,6 +213,7 @@ help:
@echo " make client Build client only" @echo " make client Build client only"
@echo " make wasm Build WASM client" @echo " make wasm Build WASM client"
@echo " make wasm-full Build WASM with dependencies" @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 " make dev Build with race detector"
@echo "" @echo ""
@echo "Run targets:" @echo "Run targets:"
@@ -215,14 +223,15 @@ help:
@echo " make wasm-serve Serve WASM client (port 8081)" @echo " make wasm-serve Serve WASM client (port 8081)"
@echo "" @echo ""
@echo "Test targets:" @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-api Run API tests"
@echo " make test-db Run database tests" @echo " make test-db Run database tests"
@echo " make test-longpoll Run long-poll tests" @echo " make test-longpoll Run long-poll tests"
@echo "" @echo ""
@echo "Database targets:" @echo "Database targets (CHESS_DSN=$(CHESS_DSN)):"
@echo " make db-init Initialize database" @echo " make db-init Create or migrate the schema"
@echo " make db-clean Remove database (destructive)" @echo " make db-clean Drop all chess tables (destructive)"
@echo "" @echo ""
@echo "WASM targets:" @echo "WASM targets:"
@echo " make wasm-deps Download xterm.js dependencies" @echo " make wasm-deps Download xterm.js dependencies"
+40 -27
View File
@@ -23,11 +23,13 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
- Custom FEN position support - Custom FEN position support
- Asynchronous engine move calculation - Asynchronous engine move calculation
- Configurable engine strength and thinking time - Configurable engine strength and thinking time
- SQLite persistence with async writes for games - PostgreSQL 18 persistence with ordered async writes for games
- Durable game results, player claims, ordered move history, and replay API - 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 - Authenticated stored-game listing with bounded pagination
- Configurable structured debug logs for persistence, cleanup, and engine work - 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 - PID file management for singleton enforcement
- Database CLI for storage and user administration - 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+ - Go 1.26+
- Stockfish chess engine (`stockfish` in PATH) - Stockfish chess engine (`stockfish` in PATH)
- SQLite3 (for persistence features) - PostgreSQL 18 (for persistence and accounts; see [Deployment](./doc/deployment.md))
### Installation ### Installation
```bash ```bash
@@ -43,9 +45,13 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
yay -S stockfish yay -S stockfish
# FreeBSD # 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 ## Quick Start
### Using Make (Recommended) ### Using Make (Recommended)
@@ -54,20 +60,22 @@ git clone https://github.com/lixenwraith/chess
cd chess cd chess
make build 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 # Standard mode with persistence and auth
make run-server make run-server
# Or run with web UI # Or run with web UI
make run-server-web make run-server-web
# Initialize database with user support
make db-init
# View all build options # View all build options
make help make help
# Add users via CLI # 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 ### Building Manually
@@ -78,53 +86,57 @@ cd chess
go build ./cmd/chess-server go build ./cmd/chess-server
# Standard mode with persistence and auth # 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 # 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 # 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 # Initialize the schema
./chess-server db init -path chess.db ./chess-server db init -dsn 'dbname=chess'
# Add users via CLI # Add users via CLI (-dsn defaults to $CHESS_DSN)
./chess-server db user add -path chess.db -username alice -password AlicePass123 ./chess-server db user add -dsn 'dbname=chess' -username alice -password AlicePass123
./chess-server db user list -path chess.db ./chess-server db user list -dsn 'dbname=chess'
``` ```
Server listens on `http://localhost:8080`. See [API Reference](./doc/api.md) for endpoints including authentication. Server listens on `http://localhost:8080`. See [API Reference](./doc/api.md) for endpoints including authentication.
## User Management ## 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 ### Creating Users
```bash ```bash
# Add user with password # 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 # 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 # 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 ### Managing Users
```bash ```bash
# List all users # List all users
./chess-server db user list -path chess.db ./chess-server db user list
# Update password # 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 # 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 # Delete user
./chess-server db user delete -path chess.db -username alice ./chess-server db user delete -username alice
``` ```
## Web UI ## 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 ./chess-server -serve -web-port 3000 -web-host 0.0.0.0
# Full example with authentication enabled # 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 # Override the API origin seen by browsers when it differs from the listen address
./chess-server -serve -web-api-url https://api.example.com ./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 ## Documentation
- [API Reference](./doc/api.md) - Endpoint specifications including auth - [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 - [Architecture](./doc/architecture.md) - System design with auth layer
- [Development](./doc/development.md) - Build, test, and user management - [Development](./doc/development.md) - Build, test, and user management
- [Client Guide](./doc/client.md) - Interactive debugging client - [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 - [Stockfish Integration](./doc/stockfish.md) - Engine communication
## License ## License
+1 -1
View File
@@ -9,4 +9,4 @@ import (
func handleExit() (restart bool) { func handleExit() (restart bool) {
display.Println(display.Cyan, "Goodbye!") display.Println(display.Cyan, "Goodbye!")
return false return false
} }
+1 -1
View File
@@ -18,4 +18,4 @@ func handleExit() (restart bool) {
display.Println(display.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") display.Println(display.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
return true return true
} }
+174 -220
View File
@@ -18,6 +18,8 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
const dsnEnv = "CHESS_DSN"
// Run is the entry point for the CLI mini-app // Run is the entry point for the CLI mini-app
func Run(args []string) error { func Run(args []string) error {
if len(args) == 0 { if len(args) == 0 {
@@ -41,74 +43,94 @@ func Run(args []string) error {
} }
} }
func runInit(args []string) error { // newFlagSet returns a flag set with the shared -dsn flag. An empty -dsn falls
fs := flag.NewFlagSet("init", flag.ContinueOnError) // back to $CHESS_DSN (in openStore, so usage output never prints it), which
path := fs.String("path", "", "Database file path (required)") // 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 { if err := fs.Parse(args); err != nil {
return err return err
} }
if *path == "" { store, err := openStore(*dsn, false)
return fmt.Errorf("database path required")
}
store, err := storage.NewStore(*path, false)
if err != nil { if err != nil {
return fmt.Errorf("failed to create store: %w", err) return err
} }
defer store.Close() defer store.Close()
if err := store.InitDB(); err != nil { if err := store.InitDB(); err != nil {
return fmt.Errorf("failed to initialize database: %w", err) return fmt.Errorf("failed to initialize database: %w", err)
} }
version, err := store.SchemaVersion()
fmt.Printf("Database initialized at: %s\n", *path) if err != nil {
return err
}
fmt.Printf("Database schema ready (version %d)\n", version)
return nil return nil
} }
func runDelete(args []string) error { func runDelete(args []string) error {
fs := flag.NewFlagSet("delete", flag.ContinueOnError) fs, dsn := newFlagSet("delete")
path := fs.String("path", "", "Database file path (required)") confirm := fs.Bool("confirm", false, "Confirm dropping all chess tables and data")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
if !*confirm {
if *path == "" { return fmt.Errorf("refusing to drop all chess tables without -confirm")
return fmt.Errorf("database path required")
} }
store, err := storage.NewStore(*path, false) store, err := openStore(*dsn, false)
if err != nil { if err != nil {
return fmt.Errorf("failed to open store: %w", err) return err
} }
defer store.Close()
if err := store.DeleteDB(); err != nil { // ☣ DESTRUCTIVE: drops every chess table in the connection's search_path
return fmt.Errorf("failed to delete database: %w", err) if err := store.DropSchema(); err != nil {
return err
} }
fmt.Println("Chess tables dropped")
fmt.Printf("Database deleted: %s\n", *path)
return nil return nil
} }
func runQuery(args []string) error { func runQuery(args []string) error {
fs := flag.NewFlagSet("query", flag.ContinueOnError) fs, dsn := newFlagSet("query")
path := fs.String("path", "", "Database file path (required)")
gameID := fs.String("gameId", "", "Game ID to filter (optional, * for all)") 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 { if err := fs.Parse(args); err != nil {
return err return err
} }
if *path == "" { store, err := openStore(*dsn, true)
return fmt.Errorf("database path required")
}
store, err := storage.NewStore(*path, false)
if err != nil { if err != nil {
return fmt.Errorf("failed to open store: %w", err) return err
} }
defer store.Close() 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 { func runUserAdd(args []string) error {
fs := flag.NewFlagSet("user add", flag.ContinueOnError) fs, dsn := newFlagSet("user add")
path := fs.String("path", "", "Database file path (required)")
username := fs.String("username", "", "Username (required)") username := fs.String("username", "", "Username (required)")
email := fs.String("email", "", "Email address (optional)") email := fs.String("email", "", "Email address (optional)")
password := fs.String("password", "", "Password (optional, will prompt if not provided)") password := fs.String("password", "", "Password (optional, will prompt if not provided)")
hash := fs.String("hash", "", "Pre-computed password hash (optional)") hash := fs.String("hash", "", "Pre-computed password hash (optional)")
interactive := fs.Bool("interactive", false, "Interactive password prompt") 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 { if err := fs.Parse(args); err != nil {
return err return err
} }
if *path == "" {
return fmt.Errorf("database path required")
}
if *username == "" { if *username == "" {
return fmt.Errorf("username required") return fmt.Errorf("username required")
} }
// Validate password/hash options
if *password != "" && *hash != "" { if *password != "" && *hash != "" {
return fmt.Errorf("cannot specify both -password and -hash") return fmt.Errorf("cannot specify both -password and -hash")
} }
var passwordHash string var passwordHash string
switch {
if *interactive { case *interactive:
if *password != "" || *hash != "" { if *password != "" || *hash != "" {
return fmt.Errorf("cannot use -interactive with -password or -hash") return fmt.Errorf("cannot use -interactive with -password or -hash")
} }
fmt.Print("Enter password: ") entered, err := readPassword("Enter password: ")
pwBytes, err := term.ReadPassword(syscall.Stdin)
fmt.Println()
if err != nil { if err != nil {
return fmt.Errorf("failed to read password: %w", err) return err
} }
if len(pwBytes) < 8 { if passwordHash, err = hashPassword(entered); err != nil {
return fmt.Errorf("password must be at least 8 characters") return err
} }
case *hash != "":
// Hash password (Argon2) if err := auth.ValidatePHCHashFormat(*hash); err != nil {
passwordHash, err = auth.HashPassword(string(pwBytes)) return fmt.Errorf("invalid hash format: %w", err)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
} }
} else if *hash != "" {
passwordHash = *hash passwordHash = *hash
} else if *password != "" { case *password != "":
if len(*password) < 8 {
return fmt.Errorf("password must be at least 8 characters")
}
// Hash password (Argon2)
var err error var err error
passwordHash, err = auth.HashPassword(*password) if passwordHash, err = hashPassword(*password); err != nil {
if err != nil { return err
return fmt.Errorf("failed to hash password: %w", err)
} }
} else { default:
return fmt.Errorf("password required: use -password, -hash, or -interactive") return fmt.Errorf("password required: use -password, -hash, or -interactive")
} }
store, err := storage.NewStore(*path, false) store, err := openStore(*dsn, true)
if err != nil { if err != nil {
return fmt.Errorf("failed to open store: %w", err) return err
} }
defer store.Close() defer store.Close()
// Generate user ID with conflict check userID := uuid.NewString()
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
}
record := storage.UserRecord{ record := storage.UserRecord{
UserID: userID, UserID: userID,
Username: strings.ToLower(*username), Username: strings.ToLower(*username),
Email: strings.ToLower(*email), Email: strings.ToLower(*email),
PasswordHash: passwordHash, PasswordHash: passwordHash,
AccountType: accountType,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
} }
if err := store.CreateUser(record); err != nil { if err := store.CreateUser(record); err != nil {
@@ -298,50 +299,59 @@ func runUserAdd(args []string) error {
fmt.Printf("User created successfully:\n") fmt.Printf("User created successfully:\n")
fmt.Printf(" ID: %s\n", userID) fmt.Printf(" ID: %s\n", userID)
fmt.Printf(" Username: %s\n", *username) fmt.Printf(" Username: %s\n", record.Username)
if *email != "" { if record.Email != "" {
fmt.Printf(" Email: %s\n", *email) fmt.Printf(" Email: %s\n", record.Email)
} }
return nil 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 { func runUserDelete(args []string) error {
fs := flag.NewFlagSet("user delete", flag.ContinueOnError) fs, dsn := newFlagSet("user delete")
path := fs.String("path", "", "Database file path (required)")
username := fs.String("username", "", "Username to delete") username := fs.String("username", "", "Username to delete")
userID := fs.String("id", "", "User ID to delete") userID := fs.String("id", "", "User ID to delete")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
if *path == "" { store, err := openStore(*dsn, true)
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)
if err != nil { if err != nil {
return fmt.Errorf("failed to open store: %w", err) return err
} }
defer store.Close() defer store.Close()
var targetID string targetID, err := lookupUser(store, *username, *userID)
if *userID != "" { if err != nil {
targetID = *userID return err
} else {
user, err := store.GetUserByUsername(*username)
if err != nil {
return fmt.Errorf("user not found: %s", *username)
}
targetID = user.UserID
} }
if err := store.DeleteUser(targetID); err != nil { if err := store.DeleteUser(targetID); err != nil {
return fmt.Errorf("failed to delete user: %w", err) return fmt.Errorf("failed to delete user: %w", err)
} }
@@ -351,65 +361,49 @@ func runUserDelete(args []string) error {
} }
func runUserSetPassword(args []string) error { func runUserSetPassword(args []string) error {
fs := flag.NewFlagSet("user set-password", flag.ContinueOnError) fs, dsn := newFlagSet("user set-password")
path := fs.String("path", "", "Database file path (required)")
username := fs.String("username", "", "Username (required)") username := fs.String("username", "", "Username (required)")
password := fs.String("password", "", "New password") password := fs.String("password", "", "New password")
interactive := fs.Bool("interactive", false, "Interactive password prompt") interactive := fs.Bool("interactive", false, "Interactive password prompt")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
if *path == "" {
return fmt.Errorf("database path required")
}
if *username == "" { if *username == "" {
return fmt.Errorf("username required") return fmt.Errorf("username required")
} }
var newPassword string var newPassword string
if *interactive { switch {
case *interactive:
if *password != "" { if *password != "" {
return fmt.Errorf("cannot use -interactive with -password") return fmt.Errorf("cannot use -interactive with -password")
} }
fmt.Print("Enter new password: ") entered, err := readPassword("Enter new password: ")
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil { if err != nil {
return fmt.Errorf("failed to read password: %w", err) return err
} }
newPassword = string(pwBytes) newPassword = entered
} else if *password != "" { case *password != "":
newPassword = *password newPassword = *password
} else { default:
return fmt.Errorf("password required: use -password or -interactive") return fmt.Errorf("password required: use -password or -interactive")
} }
passwordHash, err := hashPassword(newPassword)
if len(newPassword) < 8 { if err != nil {
return fmt.Errorf("password must be at least 8 characters") return err
} }
store, err := storage.NewStore(*path, false) store, err := openStore(*dsn, true)
if err != nil { if err != nil {
return fmt.Errorf("failed to open store: %w", err) return err
} }
defer store.Close() defer store.Close()
// Get user targetID, err := lookupUser(store, *username, "")
user, err := store.GetUserByUsername(*username)
if err != nil { if err != nil {
return fmt.Errorf("user not found: %s", *username) return err
} }
if err := store.UpdateUserPassword(targetID, passwordHash); err != nil {
// 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 {
return fmt.Errorf("failed to update password: %w", err) return fmt.Errorf("failed to update password: %w", err)
} }
@@ -418,43 +412,33 @@ func runUserSetPassword(args []string) error {
} }
func runUserSetHash(args []string) error { func runUserSetHash(args []string) error {
fs := flag.NewFlagSet("user set-hash", flag.ContinueOnError) fs, dsn := newFlagSet("user set-hash")
path := fs.String("path", "", "Database file path (required)")
username := fs.String("username", "", "Username (required)") username := fs.String("username", "", "Username (required)")
hash := fs.String("hash", "", "Password hash (required)") hash := fs.String("hash", "", "Password hash (required)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
if *path == "" {
return fmt.Errorf("database path required")
}
if *username == "" { if *username == "" {
return fmt.Errorf("username required") return fmt.Errorf("username required")
} }
if *hash == "" { if *hash == "" {
return fmt.Errorf("password hash required") return fmt.Errorf("password hash required")
} }
if err := auth.ValidatePHCHashFormat(*hash); err != nil { if err := auth.ValidatePHCHashFormat(*hash); err != nil {
return fmt.Errorf("invalid hash format: %w", err) return fmt.Errorf("invalid hash format: %w", err)
} }
store, err := storage.NewStore(*path, false) store, err := openStore(*dsn, true)
if err != nil { if err != nil {
return fmt.Errorf("failed to open store: %w", err) return err
} }
defer store.Close() defer store.Close()
// Get user targetID, err := lookupUser(store, *username, "")
user, err := store.GetUserByUsername(*username)
if err != nil { if err != nil {
return fmt.Errorf("user not found: %s", *username) return err
} }
if err := store.UpdateUserPassword(targetID, *hash); err != nil {
// Update password hash directly
if err := store.UpdateUserPassword(user.UserID, *hash); err != nil {
return fmt.Errorf("failed to update password hash: %w", err) return fmt.Errorf("failed to update password hash: %w", err)
} }
@@ -463,18 +447,12 @@ func runUserSetHash(args []string) error {
} }
func runUserSetEmail(args []string) error { func runUserSetEmail(args []string) error {
fs := flag.NewFlagSet("user set-email", flag.ContinueOnError) fs, dsn := newFlagSet("user set-email")
path := fs.String("path", "", "Database file path (required)")
username := fs.String("username", "", "Username (required)") username := fs.String("username", "", "Username (required)")
email := fs.String("email", "", "New email address (required)") email := fs.String("email", "", "New email address (required)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
if *path == "" {
return fmt.Errorf("database path required")
}
if *username == "" { if *username == "" {
return fmt.Errorf("username required") return fmt.Errorf("username required")
} }
@@ -482,20 +460,17 @@ func runUserSetEmail(args []string) error {
return fmt.Errorf("email required") return fmt.Errorf("email required")
} }
store, err := storage.NewStore(*path, false) store, err := openStore(*dsn, true)
if err != nil { if err != nil {
return fmt.Errorf("failed to open store: %w", err) return err
} }
defer store.Close() defer store.Close()
// Get user targetID, err := lookupUser(store, *username, "")
user, err := store.GetUserByUsername(*username)
if err != nil { if err != nil {
return fmt.Errorf("user not found: %s", *username) return err
} }
if err := store.UpdateUserEmail(targetID, *email); err != nil {
// Update email
if err := store.UpdateUserEmail(user.UserID, strings.ToLower(*email)); err != nil {
return fmt.Errorf("failed to update email: %w", err) return fmt.Errorf("failed to update email: %w", err)
} }
@@ -504,61 +479,46 @@ func runUserSetEmail(args []string) error {
} }
func runUserSetUsername(args []string) error { func runUserSetUsername(args []string) error {
fs := flag.NewFlagSet("user set-username", flag.ContinueOnError) fs, dsn := newFlagSet("user set-username")
path := fs.String("path", "", "Database file path (required)")
current := fs.String("current", "", "Current username (required)") 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 { if err := fs.Parse(args); err != nil {
return err return err
} }
if *path == "" {
return fmt.Errorf("database path required")
}
if *current == "" { if *current == "" {
return fmt.Errorf("current username required") return fmt.Errorf("current username required")
} }
if *new == "" { if *newName == "" {
return fmt.Errorf("new username required") return fmt.Errorf("new username required")
} }
store, err := storage.NewStore(*path, false) store, err := openStore(*dsn, true)
if err != nil { if err != nil {
return fmt.Errorf("failed to open store: %w", err) return err
} }
defer store.Close() defer store.Close()
// Get user targetID, err := lookupUser(store, *current, "")
user, err := store.GetUserByUsername(*current)
if err != nil { if err != nil {
return fmt.Errorf("user not found: %s", *current) return err
} }
if err := store.UpdateUserUsername(targetID, *newName); err != nil {
// Update username
if err := store.UpdateUserUsername(user.UserID, strings.ToLower(*new)); err != nil {
return fmt.Errorf("failed to update username: %w", err) 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 return nil
} }
func runUserList(args []string) error { func runUserList(args []string) error {
fs := flag.NewFlagSet("user list", flag.ContinueOnError) fs, dsn := newFlagSet("user list")
path := fs.String("path", "", "Database file path (required)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
if *path == "" { store, err := openStore(*dsn, true)
return fmt.Errorf("database path required")
}
store, err := storage.NewStore(*path, false)
if err != nil { if err != nil {
return fmt.Errorf("failed to open store: %w", err) return err
} }
defer store.Close() defer store.Close()
@@ -574,8 +534,8 @@ func runUserList(args []string) error {
// Print results in tabular format // Print results in tabular format
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "User ID\tUsername\tType\tEmail\tCreated\tExpires\tLast Login") fmt.Fprintln(w, "User ID\tUsername\tEmail\tCreated\tLast Login")
fmt.Fprintln(w, strings.Repeat("-", 120)) fmt.Fprintln(w, strings.Repeat("-", 100))
for _, u := range users { for _, u := range users {
lastLogin := "never" lastLogin := "never"
@@ -586,17 +546,11 @@ func runUserList(args []string) error {
if email == "" { if email == "" {
email = "(none)" email = "(none)"
} }
expires := "never" fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
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",
abbreviateID(u.UserID), abbreviateID(u.UserID),
u.Username, u.Username,
u.AccountType,
email, email,
u.CreatedAt.Format("2006-01-02 15:04"), u.CreatedAt.Format("2006-01-02 15:04"),
expires,
lastLogin, lastLogin,
) )
} }
+58 -19
View File
@@ -41,12 +41,17 @@ func main() {
apiHost = flag.String("api-host", "localhost", "API server host") apiHost = flag.String("api-host", "localhost", "API server host")
apiPort = flag.Int("api-port", 8080, "API server port") apiPort = flag.Int("api-port", 8080, "API server port")
dev = flag.Bool("dev", false, "Development mode (relaxed rate limits)") 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") 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)") 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") logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, or error")
logHTTP = flag.Bool("log-http", true, "Log HTTP requests") 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)") 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 // Web UI server flags
serve = flag.Bool("serve", false, "Enable web UI server") 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)") webAPIURL = flag.String("web-api-url", "", "Browser-visible API base URL (defaults to the API listen address)")
) )
flag.Parse() 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 var level slog.Level
if err := level.UnmarshalText([]byte(*logLevel)); err != nil { if err := level.UnmarshalText([]byte(*logLevel)); err != nil {
@@ -73,6 +86,11 @@ func main() {
// handler. Avoid embedding a second timestamp inside its message. // handler. Avoid embedding a second timestamp inside its message.
log.SetFlags(0) log.SetFlags(0)
trustedProxies, err := parseTrustedProxies(*proxies)
if err != nil {
log.Fatalf("Invalid -trusted-proxies: %v", err)
}
// Validate PID flags // Validate PID flags
if *pidLock && *pidPath == "" { if *pidLock && *pidPath == "" {
log.Fatal("Error: -pid-lock flag requires the -pid flag to be set") 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) 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 var store *storage.Store
if *storagePath != "" { if *dsn != "" {
log.Printf("Initializing persistent storage at: %s", *storagePath) log.Printf("Initializing PostgreSQL storage")
var err error var err error
store, err = storage.NewStore(*storagePath, *dev) store, err = storage.NewStore(*dsn)
if err != nil { if err != nil {
log.Fatalf("Failed to initialize storage: %v", err) log.Fatalf("Failed to initialize storage: %v", err)
} }
@@ -101,27 +120,37 @@ func main() {
log.Fatalf("Failed to initialize schema: %v", err) log.Fatalf("Failed to initialize schema: %v", err)
} }
} else { } 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 var jwtSecret []byte
if *dev { switch {
// Fixed secret in dev mode for testing consistency 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") jwtSecret = []byte("dev-secret-minimum-32-characters-long")
log.Printf("Using fixed JWT secret (dev mode)") log.Printf("Using fixed JWT secret (dev mode)")
} else { default:
// Generate cryptographically secure secret
jwtSecret = make([]byte, 32) jwtSecret = make([]byte, 32)
if _, err := rand.Read(jwtSecret); err != nil { rand.Read(jwtSecret)
log.Fatalf("Failed to generate JWT secret: %v", err) log.Printf("JWT secret generated (sessions valid until restart; use -jwt-secret-file to persist)")
}
log.Printf("JWT secret generated (sessions valid until restart)")
} }
// 2. Initialize the Service with optional storage and auth // 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.SetFinishedGameTTL(*finishedTTL)
svc.SetAnonymousGameTTL(*anonTTL)
svc.SetMaxUsers(*maxUsers)
// Start cleanup job for expired users/sessions // Start cleanup job for expired users/sessions
cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
@@ -141,7 +170,12 @@ func main() {
} }
// 4. Initialize the Fiber App/HTTP Handler, injecting processor and service // 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 // API Server configuration
apiAddr := fmt.Sprintf("%s:%d", *apiHost, *apiPort) apiAddr := fmt.Sprintf("%s:%d", *apiHost, *apiPort)
@@ -157,8 +191,13 @@ func main() {
} else { } else {
log.Printf("Rate Limit: 10 requests/second per IP") log.Printf("Rate Limit: 10 requests/second per IP")
} }
if *storagePath != "" { if len(trustedProxies) > 0 {
log.Printf("Storage: Enabled (%s)", *storagePath) 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 { } else {
log.Printf("Storage: Disabled (auth features unavailable)") log.Printf("Storage: Disabled (auth features unavailable)")
} }
+1 -1
View File
@@ -102,4 +102,4 @@ func checkStalePID(path string) error {
// Process is running // Process is running
return fmt.Errorf("stale PID file: process %d is running but not holding lock", pid) return fmt.Errorf("stale PID file: process %d is running but not holding lock", pid)
} }
+28
View File
@@ -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
}
+21
View File
@@ -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)
}
}
}
+49
View File
@@ -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
}
+49
View File
@@ -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")
}
}
+18
View File
@@ -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
+23
View File
@@ -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
+24
View File
@@ -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"
+19
View File
@@ -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;
}
+36
View File
@@ -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"
+284
View File
@@ -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
+171
View File
@@ -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
+63
View File
@@ -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
View File
@@ -26,6 +26,10 @@ Creates new user account and returns JWT token.
- `email` (string, optional): Valid email address - `email` (string, optional): Valid email address
- `password` (string, required): Minimum 8 characters, must contain letter and number - `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):** **Response (201):**
```json ```json
{ {
@@ -101,7 +105,7 @@ Returns server and storage status.
``` ```
Storage states: Storage states:
- `"disabled"` - No storage path configured - `"disabled"` - No database configured (`-dsn` or `CHESS_DSN`)
- `"ok"` - Database operational with auth enabled - `"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 - `"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` `GET /games/{gameId}/history`
Returns the persisted replay line even after the live game has been unloaded 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 the game ID, matching the existing public live-game read model. Persistent
storage must be enabled. storage must be enabled.
@@ -193,7 +199,7 @@ client can replay the game without running a chess engine.
"startTimeUtc": "2026-09-07T12:00:00Z", "startTimeUtc": "2026-09-07T12:00:00Z",
"endTimeUtc": "2026-09-07T12:15:00Z", "endTimeUtc": "2026-09-07T12:15:00Z",
"players": { "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} "black": {"id": "player-id", "color": 2, "type": 1}
}, },
"moves": [ "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 `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 Returns 400 for a non-canonical game ID, 404 when the game has no durable
disabled. record, and 503 when persistence is disabled or degraded.
### List My Stored Games ### List My Stored Games
`GET /users/me/games?limit=50&offset=0` `GET /users/me/games?limit=50&offset=0`
@@ -224,8 +232,11 @@ persistent storage.
- `limit`: 1-100; defaults to 50 - `limit`: 1-100; defaults to 50
- `offset`: 0-1,000,000; defaults to 0 - `offset`: 0-1,000,000; defaults to 0
Each item contains game ID, initial FEN, result/timestamps, players, and move Each item contains game ID, initial FEN, final FEN (after the last stored move,
count. `nextOffset` is present only when another page exists. 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):** **Response (200):**
```json ```json
@@ -234,12 +245,13 @@ count. `nextOffset` is present only when another page exists.
{ {
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", "gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "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", "result": "white_wins",
"startTimeUtc": "2026-09-07T12:00:00Z", "startTimeUtc": "2026-09-07T12:00:00Z",
"endTimeUtc": "2026-09-07T12:15:00Z", "endTimeUtc": "2026-09-07T12:15:00Z",
"moveCount": 41, "moveCount": 7,
"players": { "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} "black": {"id": "player-id", "color": 2, "type": 1}
} }
} }
@@ -314,7 +326,12 @@ Error codes:
- Registration: 5 requests/minute/IP - Registration: 5 requests/minute/IP
- Login: 10 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 ## JWT Token Format
@@ -323,6 +340,9 @@ Tokens are HS256-signed JWTs valid for 7 days. Include in Authorization header:
Authorization: Bearer <token> Authorization: Bearer <token>
``` ```
Token claims include `sub` (user ID), `username`, `email`, `session_id`, and The scheme is case-insensitive and the token must use RFC 6750 syntax; a
`exp` (expiration). Authentication requires the session to exist, be unexpired, malformed header returns 401 even on endpoints where authentication is
and belong to the JWT subject. optional. Claims are `iss` (`chess-server`), `aud` (`chess-api`), `sub` (user
ID), `iat`, `nbf`, `exp`, and `extra.session_id`. Tokens carry no profile data;
use `/auth/me`. Authentication requires the session to exist, be unexpired, and
belong to the JWT subject. Logging in again replaces the previous session.
+117 -79
View File
@@ -12,33 +12,47 @@ Central command handler containing business logic. Single `Execute(Command)` ent
In-memory live-game storage with authentication support. A mutex protects each In-memory live-game storage with authentication support. A mutex protects each
game transition and callers receive immutable game views rather than mutable game transition and callers receive immutable game views rather than mutable
game pointers. The service manages game lifecycle, snapshots, player game pointers. The service manages game lifecycle, snapshots, player
configuration, user accounts, JWT tokens, persistence, and terminal-game configuration, user accounts, JWT tokens, persistence, and cleanup. Finished
eviction. Eviction removes only the memory copy; durable replay data remains. 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`) #### 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. 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`) #### Authentication Module (`internal/service/user.go`, `internal/http/auth.go`)
- **Password Hashing**: Argon2id for secure password storage - **Password Hashing**: Argon2id (`lixenwraith/auth`), at most four concurrent
- **JWT Management**: HS256 tokens with 7-day expiration 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 - **User Operations**: Registration, login, profile management
- **Session Tracking**: One persisted session per user, with JWT subject/session - **Session Tracking**: One persisted session per user, with JWT subject/session
binding and last-login timestamps binding and last-login timestamps
### Storage Layer (`internal/server/storage`) ### Storage Layer (`internal/server/storage`)
SQLite persistence with ordered asynchronous writes for gameplay and PostgreSQL 18 persistence through pgx v5 (`database/sql` via `pgx/stdlib`),
synchronous writes for authentication. A bounded channel (1,000 operations) with ordered asynchronous writes for gameplay and synchronous writes for
feeds one transactional writer. Move persistence groups the move, first-move accounts. A bounded channel (1,000 operations) feeds one transactional writer,
slot claim, and terminal result in one transaction. Replay reads insert a which preserves per-game ordering (a move never overtakes its game's insert or
barrier behind accepted writes and then read the game and moves in one SQLite 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. snapshot.
WAL, foreign-key enforcement, a five-second busy timeout, and NORMAL The writer retries a transaction that failed before COMMIT with a transient
synchronous mode are configured in the connection string so every pooled error (lost connection, administrator shutdown, serialization failure,
connection receives the same settings. The pool is deliberately small (eight deadlock) with doubling backoff for about three seconds, so a PostgreSQL
open, four idle) because SQLite has one writer. A write failure or full queue restart does not degrade storage. A COMMIT error (unknown outcome), a permanent
marks storage degraded; live play remains available in memory and `/health` error, or a full queue marks storage degraded; live play remains available in
reports that durable history may be incomplete. 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 ### Supporting Modules
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication - **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
@@ -53,9 +67,9 @@ reports that durable history may be incomplete.
### User Registration ### User Registration
1. HTTP handler receives `POST /auth/register` with credentials 1. HTTP handler receives `POST /auth/register` with credentials
2. Validates username format and password strength 2. Validates username format and password strength
3. Service layer hashes password with Argon2id 3. Service layer hashes password with Argon2id (bounded concurrency)
4. Creates user record with unique ID (collision detection) 4. Creates the user and its initial session in one transaction (random UUIDv4 ID)
5. Generates JWT token 5. Generates a scoped JWT bound to that session
6. Returns token and user information 6. Returns token and user information
### Authenticated Game Creation ### Authenticated Game Creation
@@ -74,7 +88,7 @@ reports that durable history may be incomplete.
5. If legal, gets new FEN from engine 5. If legal, gets new FEN from engine
6. Calls `service.ApplyMoveWithState()` with the FEN, turn, and state that were validated 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 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 9. Returns GameResponse
### Computer Move ### Computer Move
@@ -100,19 +114,22 @@ reports that durable history may be incomplete.
1. Client requests `GET /api/v1/games/{id}/history` 1. Client requests `GET /api/v1/games/{id}/history`
2. Storage queues a barrier after all previously accepted gameplay writes 2. Storage queues a barrier after all previously accepted gameplay writes
3. The writer reaches the barrier only after those transactions finish 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 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 6. This path works after terminal-memory eviction or a server restart
## Persistence Flow ## Persistence Flow
### User Write Operations (Synchronous) ### User Write Operations (Synchronous)
1. Service serializes public registrations within the process 1. The password is hashed outside any lock, within the KDF concurrency bound
2. Storage checks uniqueness and capacity in a transaction 2. Storage takes a transaction-scoped advisory lock, so account creation is
3. At capacity, the oldest temporary user is evicted in that transaction serialized across the server and CLI processes
4. The new user and initial session commit together; any failure rolls back the entire operation 3. Uniqueness is checked first, so a duplicate is reported even when registration is full
5. Login replaces the user's single session with one SQLite UPSERT 4. Public registration is refused once `-max-users` accounts exist; nothing is evicted
6. Other account mutations commit before success is returned 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) ### Game Write Operations (Asynchronous)
1. Service layer calls a storage method (`RecordNewGame`, `RecordMove`, `RecordPlayers`, or `RewindGame`) 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 ### Query Operations
1. Replay-sensitive game reads wait on the async-write barrier 1. Replay-sensitive game reads wait on the async-write barrier
2. User-game filtering matches creation-time player IDs and later claim IDs 2. "My games" matches claims (`white_claimed_by`/`black_claimed_by`) through two
3. Move lookup uses the `UNIQUE(game_id, move_number)` index prefix and returns move order directly composite partial indexes; authenticated creators and first movers always
4. Username and email lookups are case-insensitive record a claim, and claims survive player reconfiguration
5. CLI database queries format records as tabular output 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 ## Concurrency
@@ -137,7 +158,8 @@ reports that durable history may be incomplete.
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes - **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
- **Validation Engine**: Single mutex-protected instance for synchronous validation - **Validation Engine**: Single mutex-protected instance for synchronous validation
- **Storage Writer**: Single goroutine processes game write queue sequentially - **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 - **PID Lock**: File-based exclusive lock prevents multiple instances
## Data Structures ## Data Structures
@@ -165,15 +187,18 @@ type Snapshot struct {
``` ```
### JWT Claims ### JWT Claims
```go ```json
{ {
"iss": "chess-server",
"aud": ["chess-api"],
"sub": "user-id", "sub": "user-id",
"username": "alice", "iat": 1234567890,
"email": "alice@example.com", "nbf": 1234567890,
"session_id": "session-id", "exp": 1235172690,
"exp": 1234567890 "extra": {"session_id": "session-id"}
} }
``` ```
Profile data is not placed in the token; clients read it from `/auth/me`.
### Command Pattern with User Context ### Command Pattern with User Context
Commands encapsulate operations with type, arguments, and optional user ID for authenticated requests. 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 ### Player Configuration
Players identified by UUID (authenticated users) or generated IDs (anonymous), configured with type (human/computer), skill level, and search time. 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 ```sql
-- User authentication table
users ( users (
user_id TEXT PRIMARY KEY, user_id uuid PRIMARY KEY,
username TEXT UNIQUE NOT NULL COLLATE NOCASE, username text NOT NULL UNIQUE, -- lowercase, 1-64 characters
email TEXT COLLATE NOCASE, email text UNIQUE, -- lowercase or NULL
password_hash TEXT NOT NULL, password_hash text NOT NULL, -- Argon2id PHC string
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at timestamptz NOT NULL,
last_login_at DATETIME 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 ( games (
game_id TEXT PRIMARY KEY, game_id uuid PRIMARY KEY,
initial_fen TEXT, initial_fen text NOT NULL,
white_player_id TEXT, -- User ID or generated UUID white_player_id uuid NOT NULL, -- user ID or generated UUID
white_type INTEGER, white_type smallint NOT NULL, -- 1 human, 2 computer
white_level INTEGER, white_level smallint NOT NULL, -- 0-20
white_search_time INTEGER, white_search_time integer NOT NULL,
black_player_id TEXT, -- User ID or generated UUID white_claimed_by uuid, -- user that owns the slot
black_type INTEGER, white_name text, -- claimant's username at claim time
black_level INTEGER, black_... (same columns),
black_search_time INTEGER, start_time_utc timestamptz NOT NULL,
start_time_utc DATETIME, result text, -- white_wins, black_wins, draw, stalemate
result TEXT, -- white_wins, black_wins, draw, or stalemate end_time_utc timestamptz, -- set exactly when result is set
end_time_utc DATETIME,
white_claimed_by TEXT, -- user that claimed the slot after creation
black_claimed_by TEXT
) )
-- Move history
moves ( moves (
move_id INTEGER PRIMARY KEY, game_id uuid REFERENCES games ON DELETE CASCADE,
game_id TEXT, move_number integer, -- 1..n without gaps
move_number INTEGER, move_uci text NOT NULL, -- [a-h][1-8][a-h][1-8][qrbn]?
move_uci TEXT, fen_after_move text NOT NULL,
fen_after_move TEXT, player_color text NOT NULL, -- 'w' | 'b'
player_color TEXT, move_time_utc timestamptz NOT NULL,
move_time_utc DATETIME, PRIMARY KEY (game_id, move_number)
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
UNIQUE (game_id, move_number)
) )
``` ```
Schema version 2 is applied idempotently to legacy databases with guarded `schema_version` records the applied version. Indexes cover session expiry,
`ALTER TABLE ADD COLUMN` migrations. Redundant indexes formerly duplicating each claim column ordered by `(start_time_utc DESC, game_id DESC)` for the game
UNIQUE constraints or useful index prefixes are removed. Purpose-built partial listing, and the start time of unclaimed games for the anonymous-game purge.
indexes cover non-empty email uniqueness, temporary-user cleanup, session Claims and player IDs deliberately have no foreign key to `users`: history
expiry, and post-creation game claims. 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 ## Security Architecture
### Authentication Flow ### Authentication Flow
1. Password validation enforces minimum complexity 1. Password validation enforces minimum complexity
2. Argon2id hashing prevents rainbow table attacks 2. Argon2id hashing prevents rainbow table attacks
3. JWT tokens expire after 7 days 3. JWT tokens expire after 7 days and must name this service as issuer/audience
4. Case-insensitive username/email matching prevents enumeration 4. Case-insensitive username/email matching prevents duplicate accounts
5. Constant-time password verification prevents timing attacks 5. Unknown accounts and wrong passwords cost the same Argon2id work and
return the same error
### Rate Limiting Strategy ### 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) - General API: 10 req/s per IP (20 in dev mode)
- Registration: 5 req/min per IP (prevent spam accounts) - Registration: 5 req/min per IP (prevent spam accounts)
- Login: 10 req/min per IP (prevent brute force) - Login: 10 req/min per IP (prevent brute force)
@@ -249,7 +286,8 @@ expiry, and post-creation game claims.
### Data Protection ### Data Protection
- Passwords never stored in plaintext - 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 - User IDs use UUIDs with collision detection
- Transactions keep registration, sessions, moves, claims, results, and rewinds internally consistent - Transactions keep registration, sessions, moves, claims, results, and rewinds internally consistent
- Case-insensitive queries prevent duplicate accounts - Case-insensitive queries prevent duplicate accounts
+286
View File
@@ -0,0 +1,286 @@
# Deployment: FreeBSD Jail with PostgreSQL 18
Production layout: host nginx terminates TLS and reverse-proxies `/chess/` to
`chess-server` in a Bastille jail. PostgreSQL 18 runs in the same jail and
listens **only on its Unix socket**. The `chess` OS account has no shell or
password; its database role reaches only the `chess` database through peer
authentication, so no database password exists.
```
internet ──TLS──▶ host nginx ──HTTP──▶ jail: chess-server (API port)
│ Unix socket, peer auth
▼
jail: PostgreSQL 18 (no TCP listener)
```
`host#` runs on the Bastille host as root; `jail#` runs inside the jail as root
(`bastille console <jail>`).
## Scripted Deployment
The scripts in [`deploy/`](../deploy) perform every step below and are safe to
rerun for upgrades. Their header comments list all inputs.
1. **Host:** give the jail its own System V shared-memory namespace, which
PostgreSQL needs, and restart it:
```sh
host# sh deploy/freebsd/host.sh chess
```
2. **Build** the static server on any machine with Go 1.26+, then copy the
binary and a checkout of this repository into the jail:
```sh
make server-freebsd # bin/chess-server-freebsd-amd64
```
3. **Jail:** install and configure everything. `OLD_SERVICE` names the rc.d
service of the SQLite deployment; `SQLITE_DB` imports its data once.
```sh
jail# CHESS_BINARY=/tmp/chess-server-freebsd-amd64 \
API_HOST=10.17.89.10 TRUSTED_PROXIES=10.17.89.1 \
OLD_SERVICE=chess SQLITE_DB=/usr/local/chess/db/chess.db \
sh deploy/freebsd/setup-jail.sh
```
It ends with a health check that requires `"storage":"ok"`.
4. **Host:** add the location block from
[`deploy/freebsd/nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf) to
the TLS server block, then `nginx -t && service nginx reload`.
5. **Verify** with the checklist at the end of this document. Keep the old
SQLite file until you are satisfied; the import does not modify it.
`setup-jail.sh` inputs:
| Variable | Default | Meaning |
|---|---|---|
| `CHESS_BINARY` | required | New `chess-server` binary |
| `API_HOST` | required | Listen address (the jail IP) |
| `API_PORT` | `8080` | Listen port |
| `TRUSTED_PROXIES` | empty (warns) | nginx address(es) as seen from the jail |
| `CHESS_HOME` | account home, else `/usr/local/chess` | Binary and JWT key location |
| `LOG_DIR` | `/var/log/chess` | Server log directory |
| `SQLITE_DB` | none | v0.11 SQLite database to import into an empty schema |
| `OLD_SERVICE` | none | Previous rc.d service to stop and disable |
| `SPLIT_PRIVILEGES` | `no` | `yes` for a DML-only runtime role (below) |
| `EXTRA_FLAGS` | none | More server flags, e.g. `-max-users 500` |
| `PGDATA` | `/var/db/postgres/data18` | PostgreSQL data directory |
What the script changes, in order:
| Step | Result |
|---|---|
| Packages | `postgresql18-server`, `postgresql18-client`, `stockfish` (and `sqlite3` for an import) |
| PostgreSQL | `initdb` with UTF-8, builtin `C.UTF-8` locale, peer local auth, host auth rejected; data checksums are on by default in 18 |
| `pg_hba.conf` | Only `postgres` (all databases) and `chess` (database `chess`), both `local` + `peer`; original kept as `pg_hba.conf.orig` |
| `postgresql.auto.conf` | `listen_addresses = ''` (no TCP), socket in `/tmp`, `scram-sha-256` |
| Provisioning | [`deploy/postgresql/setup.sql`](../deploy/postgresql/setup.sql) on first run only |
| Account | `chess` created if missing (`nologin`, no password); a warning if an existing account has a login shell |
| Files | Binary `root:wheel 0555` (previous copy kept as `.prev`); `jwt.key` generated once, `chess:chess 0600`; log directory `chess:chess 0750` |
| Schema | `chess-server db init` (as `chess`, or as `chess_owner` in split mode) |
| Import | [`migrate-sqlite.sh`](../deploy/postgresql/migrate-sqlite.sh) when `SQLITE_DB` is set and the schema is empty |
| Service | [`rc.d/chess_server`](../deploy/freebsd/rc.d/chess_server) and `rc.conf` via `sysrc` |
| Log rotation | `/usr/local/etc/newsyslog.conf.d/chess_server.conf`: daily or 1 MB, 7 kept |
| Backups | `/usr/local/sbin/chess-backup` via `/usr/local/etc/cron.d/chess-backup`: `pg_dump` at 03:30 as `postgres`, 14 days kept |
The script was exercised on Linux with FreeBSD-only commands (`pkg`, `sysrc`,
`service`, `pw`, `fetch`) stubbed and everything else real: PostgreSQL 18.6,
`su`, provisioning in both privilege modes, schema migration, the SQLite
import, and the health check of the running server.
## Accounts and Data Retention
- Accounts created by registration on the site and by `chess-server db user
add` are identical and never expire. Public registration closes at
`-max-users` accounts (default 100; `0` removes the limit); the CLI is not
limited.
- A game belongs to a registered user when they created it while signed in or
claimed a slot with their first move. Such games are kept indefinitely and
record the player's username at claim time.
- Games with no registered player are **deleted 24 hours after their last
activity** (creation, move, undo, reconfiguration, or result), from memory and
from the database, by the hourly cleanup. `-anonymous-game-ttl` changes the
window; `0` keeps them.
- Deleting a user removes their sessions; their games keep the claim and name.
## Reference
The sections below describe what the scripts configure, for review or manual
installation.
### Jail prerequisites
PostgreSQL allocates a small System V shared-memory segment even though its
main buffers use `mmap`. `host.sh` runs:
```sh
host# bastille config chess set sysvshm new
host# bastille restart chess
```
`sysvshm=new` gives the jail an isolated namespace, unlike the older shared
`allow.sysvipc`. Without it, `initdb` fails with `could not create shared
memory segment`.
### PostgreSQL
```sh
jail# pkg install postgresql18-server postgresql18-client stockfish
jail# sysrc postgresql_enable=YES
jail# sysrc postgresql_initdb_flags="--encoding=UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8 --auth-local=peer --auth-host=reject"
jail# service postgresql initdb
jail# service postgresql start
jail# su -m postgres -c "psql -X -d postgres" <<'SQL'
ALTER SYSTEM SET listen_addresses = '';
ALTER SYSTEM SET unix_socket_directories = '/tmp';
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SQL
jail# service postgresql restart
```
`/var/db/postgres/data18/pg_hba.conf`:
```
# TYPE DATABASE USER METHOD
local all postgres peer
local chess chess peer
```
### Provisioning and privilege models
[`setup.sql`](../deploy/postgresql/setup.sql) creates the login role `chess`
(no superuser, create-db, create-role, replication, or RLS bypass; 20
connections), the `chess` database (`template0`, builtin `C.UTF-8`), and the
`chess` schema. It revokes `PUBLIC` access to the database and to schema
`public`, and sets role defaults for `search_path`, `statement_timeout`,
`lock_timeout`, and `idle_in_transaction_session_timeout`.
```sh
jail# su -m postgres -c 'psql -X -d postgres -f -' < deploy/postgresql/setup.sql
```
| Mode | Schema owner | `chess` role can | Schema migrations |
|---|---|---|---|
| Owner (default) | `chess` | DML and DDL in schema `chess` only | Automatic at server start |
| Split (`-v split=true`, `SPLIT_PRIVILEGES=yes`) | `chess_owner` (NOLOGIN) | `SELECT/INSERT/UPDATE/DELETE` only | `setup-jail.sh`, or manually as `postgres` (below) |
```sh
jail# su -m postgres -c "/usr/local/chess/chess-server db init \
-dsn \"dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'\""
```
In split mode the server refuses to start while a migration is pending.
### The `chess` account
| Resource | Requirement |
|---|---|
| Shell/password | `/usr/sbin/nologin`, password disabled |
| Home | Holds `chess-server` (`root:wheel 0555`) and `jwt.key` |
| JWT key | `chess:chess 0600`, at least 32 bytes; the server refuses group- or world-readable keys |
| Log directory | `chess:chess 0750` |
| Database | Role `chess` over `/tmp/.s.PGSQL.5432`, database `chess` only |
| Network | Listens on the API port (unprivileged, above 1024) |
| Engine | `stockfish` in `PATH` (`/usr/local/bin`; set through `chess_server_env`) |
Administrative CLI commands run as `chess` so peer authentication selects the
right role; root may use `su -m` although the account has no shell:
```sh
jail# su -m chess -c '/usr/local/chess/chess-server db user list -dsn "postgres:///chess?host=/tmp"'
```
### JWT signing key
`setup-jail.sh` generates the key once with `openssl rand -base64 48`. Without
a key file, production mode generates a new key at every start and every
browser session ends on restart. To rotate, replace the file and restart the
service; users then sign in again.
### Service
`rc.conf` as written by `setup-jail.sh`:
```sh
chess_server_enable="YES"
chess_server_account="chess"
chess_server_binary="/usr/local/chess/chess-server"
chess_server_logfile="/var/log/chess/chess-server.log"
chess_server_env="PATH=/usr/local/bin:/usr/bin:/bin"
chess_server_flags="-api-host 10.17.89.10 -api-port 8080 -dsn postgres:///chess?host=/tmp -jwt-secret-file /usr/local/chess/jwt.key -log-level info -trusted-proxies 10.17.89.1"
```
The rc.d script starts `daemon(8)` as root, which writes the supervisor
pidfile, drops to `chess`, restarts the server if it exits, and reopens the log
on SIGHUP for newsyslog. It declares `REQUIRE: postgresql`. The SQLite-era
`-storage-path` flag no longer exists; a server started with it exits with
`flag provided but not defined`.
| Flag | Environment default | Purpose |
|---|---|---|
| `-dsn` | `CHESS_DSN` | PostgreSQL connection string; empty disables persistence |
| `-jwt-secret-file` | `CHESS_JWT_SECRET_FILE` | Stable JWT signing key |
| `-trusted-proxies` | | nginx address(es) as seen from the jail |
| `-proxy-header` | | Client-IP header set by nginx (default `X-Real-IP`) |
| `-max-users` | | Registration cap (default 100, `0` = none) |
| `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) |
### Host nginx
[`nginx-chess.conf`](../deploy/freebsd/nginx-chess.conf) passes the client
address in `X-Real-IP` (which nginx overwrites), allows 30-second long-polls,
and bounds request bodies. The server keys rate limits on `X-Real-IP` only for
connections from `-trusted-proxies`; any other peer is keyed by its own
address. Without `-trusted-proxies`, every proxied request shares one bucket.
The existing security headers (HSTS, `X-Frame-Options`, CSP with
`connect-src 'self'`) are unaffected: the API is same-origin under `/chess/`,
and this release does not change the web client.
### SQLite import
[`migrate-sqlite.sh`](../deploy/postgresql/migrate-sqlite.sh) reads a
schema-version-2 SQLite database (v0.11) and imports, in one transaction, all
users as regular accounts with their Argon2id hashes, unexpired sessions,
games with claims and player names, results, and moves. It refuses a schema
that already has data. Imported games without a registered player follow the
24-hour retention. Sessions carry over, but tokens signed with the old
per-restart key do not, so users sign in once. Manual invocation:
```sh
jail# su -m postgres -c 'sh /path/to/migrate-sqlite.sh /tmp/legacy.db dbname=chess'
```
### Upgrades, backups, and restores
- **Upgrade:** build the new binary and rerun `setup-jail.sh` with the same
inputs (omit `SQLITE_DB`). It keeps the previous binary as
`chess-server.prev`, migrates the schema, and restarts the service. To roll
back a release without a schema change, copy `.prev` back and restart.
- **Migrations** are transactional and serialized by an advisory lock. A
server refuses to start against a schema newer than it supports.
- **Backups:** `chess-backup` writes `pg_dump --format=custom` files to
`/var/db/postgres/backups` nightly and removes those older than 14 days. Copy
them off the jail.
- **Restore** into a freshly provisioned database:
`su -m postgres -c 'pg_restore --no-owner --role=chess -d chess <dump>'`
(`--role=chess_owner` in split mode).
- **PostgreSQL restarts:** the server retries in-flight writes across a brief
outage. A write that still fails marks storage `degraded` in `/health`; live
games continue in memory, but durable history stops until the server is
restarted.
### Verification checklist
```sh
jail# su -m chess -c 'psql -X -d chess -Atc "SHOW search_path"' # chess
jail# su -m chess -c 'psql -X -d postgres -c "select 1"' # rejected by pg_hba
jail# sockstat -4 -6 -l | grep postgres # no TCP listener
jail# service chess_server status
jail# fetch -qo - http://10.17.89.10:8080/health # "storage":"ok"
host# curl -s https://lixen.com/chess/health # through nginx
```
+85 -49
View File
@@ -4,7 +4,7 @@
- Go 1.26+ - Go 1.26+
- Stockfish in PATH - Stockfish in PATH
- SQLite3 - PostgreSQL 18 (server for persistence; `psql` for the shell test suites)
- Git - Git
- curl, jq (for testing) - curl, jq (for testing)
@@ -26,12 +26,17 @@ go build ./cmd/chess-client-cli
- `-web-host`: Web UI server host (default: localhost) - `-web-host`: Web UI server host (default: localhost)
- `-web-port`: Web UI server port (default: 9090) - `-web-port`: Web UI server port (default: 9090)
- `-dev`: Development mode with relaxed rate limits and fixed JWT secret - `-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`: PID file path for process tracking
- `-pid-lock`: Enable exclusive locking (requires -pid) - `-pid-lock`: Enable exclusive locking (requires -pid)
- `-log-level`: `debug`, `info`, `warn`, or `error` (default: `info`) - `-log-level`: `debug`, `info`, `warn`, or `error` (default: `info`)
- `-log-http`: Enable API and web request logs (default: `true`) - `-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) - `-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 - `-web-api-url`: Browser-visible API origin for the embedded web client; useful when its public origin differs from the listen address
### Modes ### Modes
@@ -40,84 +45,107 @@ go build ./cmd/chess-client-cli
./chess-server ./chess-server
# With persistence and authentication # With persistence and authentication
./chess-server -storage-path ./db/chess.db ./chess-server -dsn 'dbname=chess'
# Development with all features # 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 # 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 # Web UI is public at one origin while the API is exposed at another
./chess-server -serve -web-api-url https://api.example.test ./chess-server -serve -web-api-url https://api.example.test
# Initialize database with user tables # Create or migrate the schema
./chess-server db init -path chess.db ./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 ## 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 ### Schema Initialization
```bash ```bash
# Create all tables (users, games, moves) # Create or migrate all tables; safe to repeat
./chess-server db init -path chess.db ./chess-server db init
``` ```
### User Management CLI ### User Management CLI
```bash ```bash
# Add user with password # 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 # 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 # 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 # List all users
./chess-server db user list -path chess.db ./chess-server db user list
# Update password # 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 # 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 # 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 # 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 # Delete user
./chess-server db user delete -path chess.db -username alice ./chess-server db user delete -username alice
``` ```
### Game Query CLI ### Game Query CLI
```bash ```bash
# Query all games # Query all games
./chess-server db query -path chess.db -gameId "*" ./chess-server db query -gameId "*"
# Query games for specific user # 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 # 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) # Drop every chess table in the DSN's search_path (destructive)
./chess-server db delete -path chess.db ./chess-server db delete -confirm
``` ```
## Authentication Configuration ## Authentication Configuration
### JWT Secret Management ### JWT Secret Management
- **Production**: A cryptographically secure 32-byte secret is generated on - **Production**: Provide `-jwt-secret-file` so tokens survive restarts. The
startup. This intentionally invalidates JWTs after a restart even though the file must be a regular file with no group/other permissions and hold at
SQLite session rows remain; configuring a stable deployment secret is tracked least 32 bytes (`openssl rand -base64 48`). Without it, a random key is
in `doc/todo.md`. generated per process and every token is invalidated on restart, although
- **Development** (`-dev`): Fixed secret for testing consistency the session rows remain.
- **Sessions**: Stored for 7 days and renewed on each login; effective token - **Development** (`-dev`): Fixed secret for testing consistency when no key file is given
lifetime is also bounded by signing-key rotation - **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 ### Password Requirements
- Minimum 8 characters - Minimum 8 characters
@@ -160,7 +188,7 @@ chess/
│ │ ├── service.go # Core service │ │ ├── service.go # Core service
│ │ ├── game.go # Game operations │ │ ├── game.go # Game operations
│ │ └── user.go # User and auth operations │ │ └── user.go # User and auth operations
│ └── storage/ # SQLite persistence │ └── storage/ # PostgreSQL persistence (pgtest: per-test schemas)
│ ├── storage.go # Async writer for games │ ├── storage.go # Async writer for games
│ ├── game.go # Game persistence │ ├── game.go # Game persistence
│ ├── user.go # User persistence (synchronous) │ ├── 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 # User authentication and database tests
./test/test-db.sh ./test/test-db.sh
# Run test server with sample users # Run test server with sample users (needs a disposable CHESS_TEST_DSN)
./test/test-db-server.sh ./test/run-test-server.sh
# Test real-time game updates via long-polling # Test real-time game updates via long-polling
./test/test-longpoll.sh ./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 ./... go test ./...
# Concurrency checks for the state/persistence boundary # 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) - Queue capacity: 100 (internal/processor/queue.go)
- Min search time: 100ms (internal/processor/processor.go) - Min search time: 100ms (internal/processor/processor.go)
- Write queue: 1000 operations (internal/server/storage/storage.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) - 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 timeout: 30 seconds (internal/server/service/waiter.go)
- Long-poll channel buffer: 1 (internal/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) - Hash algorithm: Argon2id (memory-hard, side-channel resistant)
### Storage Configuration ### Storage Configuration
- WAL mode and NORMAL synchronous mode enabled on every connection - PostgreSQL via pgx; schema chosen by `search_path`; migrations run at startup
- Foreign key constraints and a five-second busy timeout enabled on every connection
- Async write pattern for games; shutdown drains every accepted write - 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) - Synchronous writes for user operations (data consistency)
- Registration capacity/eviction, user creation, and initial session are atomic - Registration limit check, user creation, and initial session are atomic
- A full queue or write failure degrades to memory-only and is visible in logs and `/health` - Games without a registered player are deleted 24 hours after their last activity
- Case-insensitive collation for usernames and emails - 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 ### Rate Limiting Configuration
- General endpoints: 10 req/s (20 in dev mode) - General endpoints: 10 req/s (20 in dev mode)
- User registration: 5 req/min - User registration: 5 req/min
- User login: 10 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 ### PID Management
- Singleton enforcement requires same PID file path - Singleton enforcement requires same PID file path
@@ -247,10 +281,11 @@ go test -race ./internal/server/storage ./internal/server/service
### Authentication Security ### Authentication Security
- Passwords hashed with Argon2id before storage - Passwords hashed with Argon2id before storage
- JWT tokens signed with HS256 - JWT tokens signed with HS256 and scoped by issuer/audience
- Constant-time password comparison - Constant-time password comparison; unknown accounts verify a dummy hash
- Case-insensitive matching prevents user enumeration - Case-insensitive matching prevents duplicate accounts
- Rate limiting on auth endpoints prevents brute force - Rate limiting on auth endpoints prevents brute force
- Argon2id concurrency is bounded to cap memory under load
### Input Validation ### Input Validation
- All user inputs validated and sanitized - All user inputs validated and sanitized
@@ -261,18 +296,19 @@ go test -race ./internal/server/storage ./internal/server/service
### Session Management ### Session Management
- JWT tokens expire after 7 days - JWT tokens expire after 7 days
- No token refresh mechanism (re-login required) - No token refresh mechanism (re-login required)
- Tokens include minimal claims (user ID, username, email) - Tokens carry only the user ID and session ID
- Secret rotates on server restart (except dev mode) - Key is stable with `-jwt-secret-file`; otherwise it rotates on restart (fixed in dev mode)
## Limitations ## Limitations
- JWT tokens don't support refresh (must re-login after expiry) - 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 password recovery mechanism
- No email verification for registration - No email verification for registration
- Fixed worker pool size for engine calculations - Fixed worker pool size for engine calculations
- No push-based game updates (30-second long-polling is used) - No push-based game updates (30-second long-polling is used)
- Live games are not rehydrated after restart; persisted games are currently replay-only - Live games are not rehydrated after restart; persisted games are currently replay-only
- Database history has no automatic retention policy - 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 - REST API only
+133 -240
View File
@@ -1,266 +1,159 @@
# Replay Implementation Tasks # Game Replay Plan
This plan covers the remaining work needed for first-class replay in the web Target experience: a user registers, signs in, plays, and later opens **My
and CLI clients, durable player-game browsing, and a curated archive of famous games** to pick any finished or unfinished game. Replay runs automatically or
games. The persistence/API foundation completed by the database audit is listed one ply at a time for either side, moves backward and forward freely, and
first so later work does not duplicate or bypass it. 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. ## Foundation in Place
- [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.
## 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 | | 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`). |
| 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 | | 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. |
| Database retention | Indefinite; only terminal in-memory state is evicted | Retention by account type, archive status, age, or explicit deletion | | 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. |
| Delete semantics | `DELETE /games/{id}` unloads memory but retains history | Name it “close/unload,” or add a separate authorized durable delete | | 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`. |
| Durability guarantee | Gameplay continues after a write failure; health becomes degraded | Keep best-effort, acknowledge writes, retry with an outbox, or fail gameplay closed | | 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. |
| Result model | `white_wins`, `black_wins`, `draw`, `stalemate` | Keep compatibility or split outcome (`1-0`, `0-1`, `1/2-1/2`) from termination reason | | 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. |
| Archived-game owner | Not created | Protected system/demo user, separate archive owner table, or ownerless source records | | 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. |
| Replay notation | UCI plus authoritative FEN after each move | Add SAN and canonical PGN at import/write time or derive them on read |
Record these choices in an ADR before changing the v1 response contract. Until ## Phase R1 — Notation Core (server, no API change)
privacy is decided, do not add searchable public player-game indexes or expose
usernames in public history.
## 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: Test gate:
`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.
### 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 ## Phase R2 — Replay API
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.
### 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 ## Phase R3 — Web Replay UI
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 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 Constraints from the host's security headers:
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.
### 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, Test gate:
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.
### 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 ## Phase R4 — CLI and WASM Replay (optional)
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 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`. - [ ] Schema: game origin (`player`, `curated`), archive metadata (event, site,
- [ ] Add searchable archive metadata: title, event, site, event date, round, date, round, names, Elo, ECO, source URL, license, attribution), publication
white/black display names, Elo values, ECO/opening, source URL, source license, state, featured order; no user ownership.
attribution text, and import timestamp. - [ ] `chess-server db archive import <pgn|dir>` using the R1 SAN decoder:
- [ ] Add publication state, featured flag, and explicit featured rank/order. validate every move, import each game in one transaction, idempotent by
- [ ] Create a protected demo/system identity only if ownership remains tied to content hash, dry-run and per-file summary.
users. It must not consume temporary-user capacity, expire, authenticate, or - [ ] Public, bounded, cacheable list endpoint; replay through the same
be evicted/deleted through normal user tools. history/PGN endpoints.
- [ ] Prefer a separate protected archive owner over credentials embedded in - [ ] Verify redistribution rights for every bundled collection.
seed scripts.
- [ ] Add only indexes backed by actual archive queries; confirm each with
`EXPLAIN QUERY PLAN` and a representative data volume.
### Import pipeline ## Operations Carried Forward
- [ ] Add `chess-server db archive import` for one PGN or a directory. - [ ] Metrics: write-queue depth, retries, degraded transitions, flush and
- [ ] Parse PGN tags, comments, NAGs, and variations deliberately; document replay latency, KDF wait time.
which are preserved and which are discarded in the first version. - [ ] Degraded-mode recovery without a restart (replay missing writes from
- [ ] Validate every main-line move from its initial position and generate the memory or fail gameplay closed); today recovery requires a restart.
authoritative FEN sequence before opening the transaction. - [ ] Live-game restoration after restart (unfinished games are replay-only).
- [ ] Import a game and all moves in one transaction. - [ ] Retention and user-initiated deletion or anonymization of games.
- [ ] Make repeated imports idempotent by source key/content hash. - [ ] Thread request contexts from HTTP handlers into storage calls; today each
- [ ] Add dry-run, structured error output, per-file summary, and all-or-nothing call carries its own deadline.
versus continue-on-error modes. - [ ] Draw and resign flows (D5) for live play, independent of replay.
- [ ] 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.
+20 -16
View File
@@ -1,33 +1,37 @@
module chess module chess
go 1.26.0 go 1.27.1
require ( require (
github.com/go-playground/validator/v10 v10.30.3 github.com/go-playground/validator/v10 v10.30.5
github.com/gofiber/fiber/v2 v2.52.14 github.com/gofiber/fiber/v2 v2.52.15
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8 github.com/jackc/pgx/v5 v5.11.0
github.com/mattn/go-sqlite3 v1.14.48 github.com/lixenwraith/auth v0.0.0-20260927004242-3b8d4a2946f1
golang.org/x/term v0.45.0 golang.org/x/term v0.46.0
) )
require ( require (
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // 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/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/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/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth 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/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.72.0 // indirect github.com/valyala/fasthttp v1.74.0 // indirect
golang.org/x/crypto v0.54.0 // indirect golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
) )
+46 -108
View File
@@ -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 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
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/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 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/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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= github.com/gofiber/fiber/v2 v2.52.15 h1:Cov1uKeVPyu9q0jSrN60W+A8XNX+/WK8J7cy5osHLIk=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/gofiber/fiber/v2 v2.52.15/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= 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-20260927004242-3b8d4a2946f1 h1:pXqSd6vdTuTh4lYdSmsoCpAJohZYAatZguE835Ru4NU=
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226/go.mod h1:1Kfy3ggtRbgrzR+qg99SaeUmmnUZKtur8uOSQsbWaPw= github.com/lixenwraith/auth v0.0.0-20260927004242-3b8d4a2946f1/go.mod h1:411JUqnbQgy9jgoFV+fH91TfVUEfzYA4dyPv7f4c264=
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/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= 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-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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/molecule-man/go-brrr v1.1.1 h1:KYwusQhjtV3smALnhmafZf4OBrh7vP4p8O47DyUmmuY=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/molecule-man/go-brrr v1.1.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= 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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 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.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE=
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+2
View File
@@ -57,6 +57,7 @@ type PlayerInfo struct {
Level int `json:"level,omitempty"` Level int `json:"level,omitempty"`
SearchTime int `json:"searchTime,omitempty"` SearchTime int `json:"searchTime,omitempty"`
ClaimedBy string `json:"claimedBy,omitempty"` ClaimedBy string `json:"claimedBy,omitempty"`
Name string `json:"name,omitempty"`
} }
type MoveInfo struct { type MoveInfo struct {
@@ -120,6 +121,7 @@ type HistoryMove struct {
type GameSummary struct { type GameSummary struct {
GameID string `json:"gameId"` GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"` InitialFEN string `json:"initialFen"`
FinalFEN string `json:"finalFen"`
Result string `json:"result,omitempty"` Result string `json:"result,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"` StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
+1 -1
View File
@@ -175,4 +175,4 @@ func setUserHandler(s *session.Session, args []string) error {
fmt.Println("Note: This doesn't authenticate, just sets the ID for display") fmt.Println("Note: This doesn't authenticate, just sets the ID for display")
return nil return nil
} }
+1 -1
View File
@@ -89,4 +89,4 @@ func rawRequestHandler(s *session.Session, args []string) error {
c := s.GetClient().(*api.Client) c := s.GetClient().(*api.Client)
return c.RawRequest(method, path, body) return c.RawRequest(method, path, body)
} }
+1 -1
View File
@@ -16,4 +16,4 @@ func readPassword(prompt string) (string, error) {
return "", err return "", err
} }
return string(bytePassword), nil return string(bytePassword), nil
} }
+1 -1
View File
@@ -25,4 +25,4 @@ func readPassword(prompt string) (string, error) {
return "", err return "", err
} }
return "", fmt.Errorf("no input received") return "", fmt.Errorf("no input received")
} }
+1 -1
View File
@@ -171,4 +171,4 @@ func exitHandler(s *session.Session, args []string) error {
// Exit is handled in main loop, this is just for consistency // Exit is handled in main loop, this is just for consistency
display.Println(display.Cyan, "Goodbye!\n") display.Println(display.Cyan, "Goodbye!\n")
return nil return nil
} }
+1 -1
View File
@@ -50,4 +50,4 @@ func ColorForTurn(turn string) string {
return Blue + "White" + Reset return Blue + "White" + Reset
} }
return Red + "Black" + Reset return Red + "Black" + Reset
} }
+1 -1
View File
@@ -49,4 +49,4 @@ func (b *Builder) String() string {
// Prompt returns a colored prompt string // Prompt returns a colored prompt string
func Prompt(text string) string { func Prompt(text string) string {
return Yellow + text + Yellow + " > " + Reset return Yellow + text + Yellow + " > " + Reset
} }
+1 -1
View File
@@ -13,4 +13,4 @@ func PrettyPrintJSON(v any) {
return return
} }
fmt.Println(string(data)) fmt.Println(string(data))
} }
+1 -1
View File
@@ -40,4 +40,4 @@ func (s *Session) SetGameState(game any) {
} }
} }
func (s *Session) SetPlayerColor(color string) { s.PlayerColor = color } func (s *Session) SetPlayerColor(color string) { s.PlayerColor = color }
func (s *Session) GetPlayerColor() string { return s.PlayerColor } func (s *Session) GetPlayerColor() string { return s.PlayerColor }
+1 -1
View File
@@ -116,4 +116,4 @@ func (b *Board) GetPieceAt(square string) byte {
file := square[0] - 'a' file := square[0] - 'a'
rank := '8' - square[1] rank := '8' - square[1]
return b.squares[rank][file] return b.squares[rank][file]
} }
+3 -1
View File
@@ -69,10 +69,12 @@ type HistoryMove struct {
} }
// GameSummary is intentionally sufficient for a client-side game picker; the // 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 { type GameSummary struct {
GameID string `json:"gameId"` GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"` InitialFEN string `json:"initialFen"`
FinalFEN string `json:"finalFen"`
Result string `json:"result,omitempty"` Result string `json:"result,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"` StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
+2 -1
View File
@@ -19,6 +19,7 @@ type Player struct {
Level int `json:"level,omitempty"` // Only for computer Level int `json:"level,omitempty"` // Only for computer
SearchTime int `json:"searchTime,omitempty"` // Only for computer SearchTime int `json:"searchTime,omitempty"` // Only for computer
ClaimedBy string `json:"claimedBy,omitempty"` // UserID that claimed this slot 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 // PlayerConfig for API requests and configuration
@@ -82,4 +83,4 @@ func OppositeColor(c Color) Color {
return ColorBlack return ColorBlack
} }
return ColorWhite return ColorWhite
} }
-1
View File
@@ -284,4 +284,3 @@ func (u *UCI) Close() error {
u.killLocked() u.killLocked()
return nil return nil
} }
+29 -18
View File
@@ -26,11 +26,12 @@ type MoveResult struct {
} }
type Game struct { type Game struct {
snapshots []Snapshot snapshots []Snapshot
players map[core.Color]*core.Player players map[core.Color]*core.Player
state core.State state core.State
lastResult *MoveResult lastResult *MoveResult
endTimeUTC *time.Time 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. // 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 = &copy g.lastResult = &copy
} }
func (g *Game) LastResult() *MoveResult {
return g.lastResult
}
// CurrentSnapshot returns the latest game snapshot // CurrentSnapshot returns the latest game snapshot
func (g *Game) CurrentSnapshot() Snapshot { func (g *Game) CurrentSnapshot() Snapshot {
return g.snapshots[len(g.snapshots)-1] return g.snapshots[len(g.snapshots)-1]
@@ -217,11 +214,8 @@ func (g *Game) State() core.State {
return g.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) { func (g *Game) SetStateAt(s core.State, at time.Time) {
g.Touch(at)
if s.IsTerminal() { if s.IsTerminal() {
if !g.state.IsTerminal() || g.endTimeUTC == nil { if !g.state.IsTerminal() || g.endTimeUTC == nil {
ended := at.UTC() ended := at.UTC()
@@ -233,6 +227,28 @@ func (g *Game) SetStateAt(s core.State, at time.Time) {
g.state = s 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 { func (g *Game) EndTimeUTC() *time.Time {
if g.endTimeUTC == nil { if g.endTimeUTC == nil {
return nil return nil
@@ -278,11 +294,6 @@ func (g *Game) GetSlotOwner(color core.Color) string {
return player.ClaimedBy 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 // HasComputerPlayer returns true if at least one player is computer
func (g *Game) HasComputerPlayer() bool { func (g *Game) HasComputerPlayer() bool {
white := g.players[core.ColorWhite] white := g.players[core.ColorWhite]
+10 -5
View File
@@ -91,22 +91,22 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
req.Email = strings.ToLower(req.Email) req.Email = strings.ToLower(req.Email)
} }
// Create the user and initial session atomically (temp by default via API). // Create the user and initial session atomically.
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password, false) user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password)
if err != nil { if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) { if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable, 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{ return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "registration temporarily unavailable", Error: "registration temporarily unavailable",
Code: core.ErrResourceLimit, Code: core.ErrResourceLimit,
Details: err.Error(), Details: err.Error(),
}) })
} }
if strings.Contains(err.Error(), "already exists") { if errors.Is(err, service.ErrUserExists) {
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{ return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
Error: "user already exists", Error: "user already exists",
Code: core.ErrInvalidRequest, Code: core.ErrInvalidRequest,
@@ -194,6 +194,11 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable, 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{ return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid credentials", Error: "invalid credentials",
Code: core.ErrInvalidRequest, Code: core.ErrInvalidRequest,
@@ -214,7 +219,7 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
UserID: user.UserID, UserID: user.UserID,
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour), ExpiresAt: time.Now().Add(service.SessionTTL),
}) })
} }
+53 -29
View File
@@ -4,7 +4,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"time" "time"
"chess/internal/server/core" "chess/internal/server/core"
@@ -30,21 +29,58 @@ func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandle
return &HTTPHandler{proc: proc, svc: svc} return &HTTPHandler{proc: proc, svc: svc}
} }
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRequests bool) *fiber.App { // Options configures the API application.
// Create handler type Options struct {
h := NewHTTPHandler(proc, svc) 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 // appConfig returns the Fiber configuration. Client-IP resolution only honors
app := fiber.New(fiber.Config{ // 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, ErrorHandler: customErrorHandler,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 35 * time.Second, WriteTimeout: 35 * time.Second,
IdleTimeout: 60 * 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) // Global middleware (order matters)
app.Use(recover.New()) app.Use(recover.New())
if logRequests { if opts.LogRequests {
app.Use(logger.New(logger.Config{ app.Use(logger.New(logger.Config{
Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n", Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n",
TimeFormat: time.RFC3339, TimeFormat: time.RFC3339,
@@ -68,11 +104,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
// Register: 5 req/min per IP // Register: 5 req/min per IP
auth.Post("/register", limiter.New(limiter.Config{ auth.Post("/register", limiter.New(limiter.Config{
Max: 5, Max: 5,
Expiration: 1 * time.Minute, Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string { KeyGenerator: clientIP,
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error { LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{ return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded", Error: "rate limit exceeded",
@@ -84,11 +118,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
// Login: 10 req/min per IP // Login: 10 req/min per IP
auth.Post("/login", limiter.New(limiter.Config{ auth.Post("/login", limiter.New(limiter.Config{
Max: 10, Max: 10,
Expiration: 1 * time.Minute, Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string { KeyGenerator: clientIP,
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error { LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{ return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded", Error: "rate limit exceeded",
@@ -113,17 +145,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
maxReq = rateLimitRate * 2 maxReq = rateLimitRate * 2
} }
api.Use(limiter.New(limiter.Config{ api.Use(limiter.New(limiter.Config{
Max: maxReq, Max: maxReq,
Expiration: 1 * time.Second, Expiration: 1 * time.Second,
KeyGenerator: func(c *fiber.Ctx) string { KeyGenerator: clientIP,
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()
},
LimitReached: func(c *fiber.Ctx) error { LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{ return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded", Error: "rate limit exceeded",
+61
View File
@@ -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)
}
}
}
+35 -45
View File
@@ -2,12 +2,12 @@ package http
import ( import (
"errors" "errors"
"strings"
"chess/internal/server/core" "chess/internal/server/core"
"chess/internal/server/service" "chess/internal/server/service"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/lixenwraith/auth"
) )
// TokenValidator validates JWT tokens // TokenValidator validates JWT tokens
@@ -16,69 +16,59 @@ type TokenValidator func(token string) (userID string, claims map[string]any, er
// AuthRequired enforces JWT authentication for protected endpoints // AuthRequired enforces JWT authentication for protected endpoints
func AuthRequired(validateToken TokenValidator) fiber.Handler { func AuthRequired(validateToken TokenValidator) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
token := extractBearerToken(c.Get("Authorization")) header := c.Get(fiber.HeaderAuthorization)
if token == "" { if header == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{ return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "missing authorization token", Error: "missing authorization token",
Code: core.ErrUnauthorized, Code: core.ErrUnauthorized,
}) })
} }
token, err := auth.ParseBearerToken(header)
userID, claims, err := validateToken(token)
if err != nil { if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) { return invalidToken(c)
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token",
Code: core.ErrUnauthorized,
})
} }
c.Locals("userID", userID) return authenticate(c, validateToken, token)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
}
return c.Next()
} }
} }
// OptionalAuth permits an absent token but rejects an invalid token instead of // OptionalAuth permits an absent Authorization header but rejects a malformed
// silently downgrading an intended authenticated request to anonymous access. // or invalid one instead of silently downgrading an intended authenticated
// request to anonymous access.
func OptionalAuth(validateToken TokenValidator) fiber.Handler { func OptionalAuth(validateToken TokenValidator) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
token := extractBearerToken(c.Get("Authorization")) header := c.Get(fiber.HeaderAuthorization)
if token == "" { if header == "" {
return c.Next() return c.Next()
} }
token, err := auth.ParseBearerToken(header)
userID, claims, err := validateToken(token)
if err != nil { if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) { return invalidToken(c)
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ }
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable, return authenticate(c, validateToken, token)
}) }
} }
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token", Code: core.ErrUnauthorized, 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) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
}) })
} }
return invalidToken(c)
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
}
return c.Next()
} }
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 invalidToken(c *fiber.Ctx) error {
func extractBearerToken(header string) string { return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
const prefix = "Bearer " Error: "invalid or expired token", Code: core.ErrUnauthorized,
if !strings.HasPrefix(header, prefix) { })
return ""
}
return strings.TrimPrefix(header, prefix)
} }
+33
View File
@@ -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)
}
}
}
}
+7 -1
View File
@@ -96,7 +96,13 @@ func validationMiddleware(c *fiber.Ctx) error {
return c.Next() 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 { func isValidUUID(s string) bool {
if len(s) != 36 {
return false
}
_, err := uuid.Parse(s) _, err := uuid.Parse(s)
return err == nil return err == nil
} }
+1 -1
View File
@@ -83,4 +83,4 @@ func NewGetBoardCommand(gameID string) Command {
Type: CmdGetBoard, Type: CmdGetBoard,
GameID: gameID, GameID: gameID,
} }
} }
-39
View File
@@ -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 // classifyCurrentLocked classifies whatever position is loaded in the
// validation engine. Caller holds p.mu, immediately after a SetPosition. // validation engine. Caller holds p.mu, immediately after a SetPosition.
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) { 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() 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 // buildGameResponse constructs standard game response
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse { func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
resp := core.GameResponse{ resp := core.GameResponse{
+2 -14
View File
@@ -127,6 +127,7 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
} }
g.UpdatePlayers(whitePlayer, blackPlayer) g.UpdatePlayers(whitePlayer, blackPlayer)
g.Touch(time.Now().UTC())
if oldHasComputer != newHasComputer { if oldHasComputer != newHasComputer {
if newHasComputer { if newHasComputer {
s.computerGames.Add(1) s.computerGames.Add(1)
@@ -302,20 +303,6 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
return nil 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 // UndoMoves removes the specified number of moves from game history
func (s *Service) UndoMoves(gameID string, count int) error { func (s *Service) UndoMoves(gameID string, count int) error {
s.mu.Lock() s.mu.Lock()
@@ -334,6 +321,7 @@ func (s *Service) UndoMoves(gameID string, count int) error {
if err := g.UndoMoves(count); err != nil { if err := g.UndoMoves(count); err != nil {
return err return err
} }
g.Touch(time.Now().UTC())
// Notify waiting clients about the undo // Notify waiting clients about the undo
s.waiter.NotifyGame(gameID, len(g.Moves()), g.State()) s.waiter.NotifyGame(gameID, len(g.Moves()), g.State())
+86 -28
View File
@@ -2,21 +2,24 @@ package service
import ( import (
"errors" "errors"
"path/filepath"
"testing" "testing"
"time" "time"
"chess/internal/server/core" "chess/internal/server/core"
"chess/internal/server/game" "chess/internal/server/game"
"chess/internal/server/storage" "chess/internal/server/storage"
"chess/internal/server/storage/pgtest"
"github.com/google/uuid"
) )
func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) { func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) {
svc := newPersistentTestService(t) svc := newPersistentTestService(t)
gameID, userID := uuid.NewString(), uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame( if err := svc.CreateGame(
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing, gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -24,53 +27,54 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC) ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
commit := MoveCommit{ commit := MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite, 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}, 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) t.Fatal(err)
} }
view, err := svc.GetGameView("game-1") view, err := svc.GetGameView(gameID)
if err != nil { if err != nil {
t.Fatal(err) 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) 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 { if err != nil {
t.Fatal(err) 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) 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) t.Fatalf("stale move error = %v, want ErrGameChanged", err)
} }
} }
func TestUndoClearsDurableTerminalResult(t *testing.T) { func TestUndoClearsDurableTerminalResult(t *testing.T) {
svc := newPersistentTestService(t) svc := newPersistentTestService(t)
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame( if err := svc.CreateGame(
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing, gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := svc.ApplyMoveWithState("game-1", MoveCommit{ if err := svc.ApplyMoveWithState(gameID, MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite, ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate, MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := svc.UndoMoves("game-1", 1); err != nil { if err := svc.UndoMoves(gameID, 1); err != nil {
t.Fatal(err) t.Fatal(err)
} }
history, err := svc.GetGameHistory("game-1") history, err := svc.GetGameHistory(gameID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -81,12 +85,13 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.T) { func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.T) {
svc := newPersistentTestService(t) svc := newPersistentTestService(t)
gameID, userID := uuid.NewString(), uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
white.ID = "user-1" white.ID = userID
white.ClaimedBy = "user-1" white.ClaimedBy = userID
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame( if err := svc.CreateGame(
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing, gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -95,15 +100,15 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
replacementBlack := core.NewPlayer( replacementBlack := core.NewPlayer(
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack, 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) t.Fatal(err)
} }
history, err := svc.GetGameHistory("game-1") history, err := svc.GetGameHistory(gameID)
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("white ownership was replaced: %+v", history.Players.White)
} }
if history.Players.Black.Type != core.PlayerComputer || history.Players.Black.Level != 12 { if history.Players.Black.Type != core.PlayerComputer || history.Players.Black.Level != 12 {
@@ -113,48 +118,101 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
computerWhite := core.NewPlayer( computerWhite := core.NewPlayer(
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite, 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) t.Fatal(err)
} }
history, err = svc.GetGameHistory("game-1") history, err = svc.GetGameHistory(gameID)
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("player-type change discarded historical ownership: %+v", history.Players.White)
} }
} }
func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) { func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
svc := newPersistentTestService(t) svc := newPersistentTestService(t)
gameID := uuid.NewString()
svc.SetFinishedGameTTL(time.Nanosecond) svc.SetFinishedGameTTL(time.Nanosecond)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame( if err := svc.CreateGame(
"game-1", white, black, "terminal", core.ColorWhite, core.StateStalemate, gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate,
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
svc.cleanupFinishedGames(time.Now().UTC().Add(time.Second)) svc.cleanupGames(time.Now().UTC().Add(time.Second))
if _, err := svc.GetGameView("game-1"); !errors.Is(err, ErrGameNotFound) { if _, err := svc.GetGameView(gameID); !errors.Is(err, ErrGameNotFound) {
t.Fatalf("terminal game remains in memory: %v", err) 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) 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 { func newPersistentTestService(t *testing.T) *Service {
t.Helper() t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "chess.db"), false) store, err := storage.NewStore(pgtest.DSN(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := store.InitDB(); err != nil { if err := store.InitDB(); err != nil {
t.Fatal(err) 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() { t.Cleanup(func() {
if err := svc.Shutdown(time.Second); err != nil { if err := svc.Shutdown(time.Second); err != nil {
t.Errorf("shutdown: %v", err) t.Errorf("shutdown: %v", err)
+3
View File
@@ -82,6 +82,7 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
StartTimeUTC: record.StartTimeUTC, StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC, EndTimeUTC: record.EndTimeUTC,
MoveCount: record.MoveCount, MoveCount: record.MoveCount,
FinalFEN: record.FinalFEN,
Players: playersResponse(record.GameRecord), Players: playersResponse(record.GameRecord),
}) })
} }
@@ -100,10 +101,12 @@ func playersResponse(record storage.GameRecord) core.PlayersResponse {
White: &core.Player{ White: &core.Player{
ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType), ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType),
Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy, Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy,
Name: record.WhiteName,
}, },
Black: &core.Player{ Black: &core.Player{
ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType), ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType),
Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy, Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy,
Name: record.BlackName,
}, },
} }
} }
+85 -84
View File
@@ -1,7 +1,6 @@
package service package service
import ( import (
"chess/internal/server/core"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -10,41 +9,62 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"chess/internal/server/core"
"chess/internal/server/game" "chess/internal/server/game"
"chess/internal/server/storage" "chess/internal/server/storage"
"github.com/lixenwraith/auth"
) )
const ( const (
MaxComputerGames = 10 MaxComputerGames = 10
MaxUsers = 100 DefaultMaxUsers = 100
PermanentSlots = 10
TempUserTTL = 24 * time.Hour
SessionTTL = 7 * 24 * time.Hour SessionTTL = 7 * 24 * time.Hour
CleanupJobInterval = 1 * time.Hour CleanupJobInterval = 1 * time.Hour
FinishedGameTTL = 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 // Service coordinates game state, user management, and storage
type Service struct { type Service struct {
games map[string]*game.Game games map[string]*game.Game
mu sync.RWMutex mu sync.RWMutex
userMu sync.Mutex
store *storage.Store store *storage.Store
jwtSecret []byte jwt *auth.JWT
kdf chan struct{} // Argon2id concurrency slots
waiter *WaitRegistry waiter *WaitRegistry
computerGames atomic.Int32 // Active games with computer players computerGames atomic.Int32 // Active games with computer players
finishedTTL time.Duration finishedTTL time.Duration
anonymousTTL time.Duration
maxUsers atomic.Int64
} }
// New creates a new service instance with optional storage // New creates a service with optional storage. jwtSecret signs session tokens
func New(store *storage.Store, jwtSecret []byte) *Service { // and must hold at least 32 bytes of key material.
return &Service{ func New(store *storage.Store, jwtSecret []byte) (*Service, error) {
games: make(map[string]*game.Game), manager, err := auth.NewJWT(jwtSecret,
store: store, auth.WithIssuer(JWTIssuer),
jwtSecret: jwtSecret, auth.WithAudience([]string{JWTAudience}),
waiter: NewWaitRegistry(), auth.WithTokenLifetime(SessionTTL),
finishedTTL: FinishedGameTTL, // 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,
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. // SetFinishedGameTTL configures how long terminal games remain in memory.
@@ -56,6 +76,22 @@ func (s *Service) SetFinishedGameTTL(ttl time.Duration) {
s.mu.Unlock() 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 // GetStorageHealth returns the storage component status
func (s *Service) GetStorageHealth() string { func (s *Service) GetStorageHealth() string {
if s.store == nil { if s.store == nil {
@@ -84,60 +120,11 @@ func (s *Service) CanCreateComputerGame() bool {
return s.computerGames.Load() < MaxComputerGames 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 // GetComputerGameCount returns current computer game count
func (s *Service) GetComputerGameCount() int32 { func (s *Service) GetComputerGameCount() int32 {
return s.computerGames.Load() 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 // Shutdown gracefully shuts down the service
func (s *Service) Shutdown(timeout time.Duration) error { func (s *Service) Shutdown(timeout time.Duration) error {
var errs []error var errs []error
@@ -159,7 +146,8 @@ func (s *Service) Shutdown(timeout time.Duration) error {
return errors.Join(errs...) 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) { func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
s.cleanupExpired() s.cleanupExpired()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
@@ -176,49 +164,62 @@ func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
} }
func (s *Service) cleanupExpired() { func (s *Service) cleanupExpired() {
now := time.Now().UTC()
if s.store != nil { 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 { if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
slog.Error("cleanup failed to delete expired sessions", "error", err) slog.Error("cleanup failed to delete expired sessions", "error", err)
} else if deleted > 0 { } else if deleted > 0 {
slog.Info("cleanup deleted expired sessions", "count", deleted) slog.Info("cleanup deleted expired sessions", "count", deleted)
} }
} }
s.cleanupGames(now)
s.cleanupFinishedGames(time.Now().UTC())
} }
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() s.mu.Lock()
if s.finishedTTL <= 0 { finishedCutoff := now.Add(-s.finishedTTL)
s.mu.Unlock() anonymousTTL := s.anonymousTTL
return anonymousCutoff := now.Add(-anonymousTTL)
} var finished, idle []string
cutoff := now.Add(-s.finishedTTL) live := make([]string, 0, len(s.games))
removed := make([]string, 0)
for gameID, g := range s.games { for gameID, g := range s.games {
ended := g.EndTimeUTC() 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 continue
} }
if g.HasComputerPlayer() { if g.HasComputerPlayer() {
s.computerGames.Add(-1) s.computerGames.Add(-1)
} }
delete(s.games, gameID) delete(s.games, gameID)
removed = append(removed, gameID)
} }
s.mu.Unlock() s.mu.Unlock()
for _, gameID := range removed { for _, gameID := range append(finished, idle...) {
s.waiter.RemoveGame(gameID) s.waiter.RemoveGame(gameID)
} }
if len(removed) > 0 { if len(finished) > 0 {
slog.Info("cleanup evicted terminal games from memory", 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)
}
} }
} }
+120 -189
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
"sync"
"time" "time"
"chess/internal/server/storage" "chess/internal/server/storage"
@@ -18,122 +19,129 @@ var (
ErrStorageDisabled = errors.New("storage disabled") ErrStorageDisabled = errors.New("storage disabled")
ErrStorageUnavailable = errors.New("storage unavailable") ErrStorageUnavailable = errors.New("storage unavailable")
ErrAtCapacity = errors.New("at capacity") 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 // User represents a registered user account
type User struct { type User struct {
UserID string UserID string
Username string Username string
Email string Email string
AccountType string CreatedAt time.Time
CreatedAt time.Time
ExpiresAt *time.Time
} }
// CreateUser creates new user with registration limits enforcement // acquireKDF reserves one Argon2id slot, waiting at most kdfWaitTimeout.
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) { func (s *Service) acquireKDF() (release func(), err error) {
user, _, err := s.createUser(username, email, password, permanent, false) timer := time.NewTimer(kdfWaitTimeout)
return user, err 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. // transaction, so a successful registration always returns a usable account.
func (s *Service) RegisterUser(username, email, password string, permanent bool) (*User, string, error) { // Registered accounts are identical to CLI-created ones and never expire.
return s.createUser(username, email, password, permanent, true) func (s *Service) RegisterUser(username, email, password string) (*User, string, error) {
}
func (s *Service) createUser(
username, email, password string,
permanent, withSession bool,
) (*User, string, error) {
s.userMu.Lock()
defer s.userMu.Unlock()
if s.store == nil { if s.store == nil {
return nil, "", ErrStorageDisabled return nil, "", ErrStorageDisabled
} }
// Determine account type // Hash before touching storage; account creation itself is serialized by
accountType := "temp" // the store, so concurrent registrations only contend for KDF slots.
var expiresAt *time.Time passwordHash, err := s.hashPassword(password)
if permanent {
accountType = "permanent"
} else {
expiry := time.Now().UTC().Add(TempUserTTL)
expiresAt = &expiry
}
// Hash password
passwordHash, err := auth.HashPassword(password)
if err != nil { if err != nil {
if errors.Is(err, ErrAuthBusy) {
return nil, "", err
}
return nil, "", fmt.Errorf("failed to hash password: %w", err) return nil, "", fmt.Errorf("failed to hash password: %w", err)
} }
// Generate unique user ID now := time.Now().UTC()
userID, err := s.generateUniqueUserID()
if err != nil {
return nil, "", fmt.Errorf("failed to generate unique ID: %w", err)
}
// Create user record // A random UUIDv4 needs no existence probe; the primary key rejects the
// negligible collision case.
userID := uuid.NewString()
user := &User{ user := &User{
UserID: userID, UserID: userID,
Username: username, Username: strings.ToLower(username),
Email: email, Email: strings.ToLower(email),
AccountType: accountType, CreatedAt: now,
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
} }
record := storage.UserRecord{ record := storage.UserRecord{
UserID: userID, UserID: userID,
Username: strings.ToLower(username), Username: user.Username,
Email: strings.ToLower(email), Email: user.Email,
PasswordHash: passwordHash, PasswordHash: passwordHash,
AccountType: accountType, CreatedAt: now,
CreatedAt: user.CreatedAt,
ExpiresAt: expiresAt,
} }
sessionID := uuid.NewString()
var sessionID string session := &storage.SessionRecord{
var session *storage.SessionRecord SessionID: sessionID,
if withSession { UserID: userID,
sessionID = uuid.New().String() CreatedAt: now,
session = &storage.SessionRecord{ ExpiresAt: now.Add(SessionTTL),
SessionID: sessionID,
UserID: userID,
CreatedAt: user.CreatedAt,
ExpiresAt: user.CreatedAt.Add(SessionTTL),
}
}
limits := storage.UserLimits{
MaxUsers: MaxUsers,
PermanentSlots: PermanentSlots,
} }
limits := storage.UserLimits{MaxUsers: int(s.maxUsers.Load())}
if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil { if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil {
switch { switch {
case errors.Is(err, storage.ErrUserAlreadyExists): case errors.Is(err, storage.ErrUserAlreadyExists):
return nil, "", fmt.Errorf("username or email already exists: %w", err) return nil, "", ErrUserExists
case errors.Is(err, storage.ErrPermanentCapacity):
return nil, "", fmt.Errorf("%w (%d maximum)", ErrPermanentSlotsFull, PermanentSlots)
case errors.Is(err, storage.ErrUserCapacity): 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: default:
return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err) return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err)
} }
} }
slog.Debug("user created", slog.Debug("user created", "user_id", userID)
"user_id", userID,
"account_type", accountType,
"initial_session", withSession,
)
return user, sessionID, nil 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) { func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
if s.store == nil { if s.store == nil {
return nil, "", ErrStorageDisabled return nil, "", ErrStorageDisabled
@@ -141,75 +149,49 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
var userRecord *storage.UserRecord var userRecord *storage.UserRecord
var err error var err error
// Check if identifier looks like email
if strings.Contains(identifier, "@") { if strings.Contains(identifier, "@") {
userRecord, err = s.store.GetUserByEmail(identifier) userRecord, err = s.store.GetUserByEmail(identifier)
} else { } else {
userRecord, err = s.store.GetUserByUsername(identifier) userRecord, err = s.store.GetUserByUsername(identifier)
} }
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
}
if err != nil { hash := ""
auth.HashPassword(password) // Timing attack prevention if userRecord != nil {
if !errors.Is(err, sql.ErrNoRows) { hash = userRecord.PasswordHash
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err) } 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, "", fmt.Errorf("invalid credentials") return nil, "", ErrInvalidCredentials
} }
// Verify password now := time.Now().UTC()
if err := auth.VerifyPassword(password, userRecord.PasswordHash); err != nil { // Replaces any existing session and records the login time.
return nil, "", fmt.Errorf("invalid credentials") sessionID := uuid.NewString()
} if err := s.store.CreateSession(storage.SessionRecord{
// 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{
SessionID: sessionID, SessionID: sessionID,
UserID: userRecord.UserID, UserID: userRecord.UserID,
CreatedAt: time.Now().UTC(), CreatedAt: now,
ExpiresAt: time.Now().UTC().Add(SessionTTL), ExpiresAt: now.Add(SessionTTL),
} }); err != nil {
if err := s.store.CreateSession(sessionRecord); err != nil {
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err) return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
} }
slog.Debug("user authenticated", "user_id", userRecord.UserID) 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{ return &User{
UserID: userRecord.UserID, UserID: userRecord.UserID,
Username: userRecord.Username, Username: userRecord.Username,
Email: userRecord.Email, Email: userRecord.Email,
AccountType: userRecord.AccountType, CreatedAt: userRecord.CreatedAt,
CreatedAt: userRecord.CreatedAt,
ExpiresAt: userRecord.ExpiresAt,
}, sessionID, nil }, 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) // InvalidateSession removes a session (logout)
func (s *Service) InvalidateSession(sessionID string) error { func (s *Service) InvalidateSession(sessionID string) error {
if s.store == nil { if s.store == nil {
@@ -236,34 +218,23 @@ func (s *Service) GetUserByID(userID string) (*User, error) {
} }
return &User{ return &User{
UserID: userRecord.UserID, UserID: userRecord.UserID,
Username: userRecord.Username, Username: userRecord.Username,
Email: userRecord.Email, Email: userRecord.Email,
AccountType: userRecord.AccountType, CreatedAt: userRecord.CreatedAt,
CreatedAt: userRecord.CreatedAt,
ExpiresAt: userRecord.ExpiresAt,
}, nil }, 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) { func (s *Service) GenerateUserToken(userID, sessionID string) (string, error) {
user, err := s.GetUserByID(userID) return s.jwt.GenerateToken(userID, map[string]any{"session_id": sessionID})
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)
} }
// ValidateToken verifies JWT token and session validity // ValidateToken verifies JWT token and session validity
func (s *Service) ValidateToken(token string) (string, map[string]any, error) { 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 { if err != nil {
return "", nil, err return "", nil, err
} }
@@ -285,43 +256,3 @@ func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
return userID, claims, nil 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
}
+114 -14
View File
@@ -1,14 +1,21 @@
package service package service
import ( import (
"errors"
"testing" "testing"
"time"
"chess/internal/server/storage"
"github.com/google/uuid"
"github.com/lixenwraith/auth" "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) svc := newPersistentTestService(t)
user, sessionID, err := svc.RegisterUser("alice", "", "Password1", false) user, sessionID, err := svc.RegisterUser("alice", "", "Password1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -17,26 +24,47 @@ func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if gotUserID, _, err := svc.ValidateToken(validToken); err != nil || gotUserID != user.UserID { gotUserID, claims, err := svc.ValidateToken(validToken)
t.Fatalf("valid token rejected: user=%q err=%v", gotUserID, err) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, _, err := svc.ValidateToken(missingSession); err == nil { unscoped, err := auth.NewJWT(testJWTSecret)
t.Fatal("token without a persisted session was accepted")
}
wrongSubject, err := auth.GenerateHS256Token(svc.jwtSecret, "other-user", map[string]any{
"session_id": sessionID,
}, SessionTTL)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, _, err := svc.ValidateToken(wrongSubject); err == nil { foreignAudience, err := auth.NewJWT(testJWTSecret,
t.Fatal("session was accepted for a different JWT subject") 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 { if err := svc.InvalidateSession(sessionID); err != nil {
@@ -46,3 +74,75 @@ func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
t.Fatal("invalidated session was accepted") 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")
}
}
+204 -214
View File
@@ -6,17 +6,29 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
) )
const gameSelectColumns = ` const gameSelectColumns = `
g.game_id, g.initial_fen, 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.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_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` 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 // RecordNewGame asynchronously records a new game. Terminal custom-FEN games
// include their result in this insert rather than relying on a second write. // 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 { func (s *Store) RecordNewGame(record GameRecord) error {
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" { if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
return errors.New("game ID, initial FEN, and player IDs are required") 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 err
} }
return s.enqueue("record_game", record.GameID, func(tx *sql.Tx) error { return s.enqueue("record_game", record.GameID, func(ctx context.Context, tx *sql.Tx) error {
const query = `INSERT INTO games ( query := `INSERT INTO games (
game_id, initial_fen, game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time, white_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_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name,
start_time_utc, result, end_time_utc 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.GameID, record.InitialFEN,
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime, record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
nullableString(record.WhiteClaimedBy), nullableString(record.WhiteClaimedBy),
@@ -65,11 +78,11 @@ func (s *Store) RecordMove(record MovePersistence) error {
return err 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 ( const insertMove = `INSERT INTO moves (
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
) VALUES (?, ?, ?, ?, ?, ?)` ) VALUES ($1, $2, $3, $4, $5, $6)`
if _, err := tx.Exec(insertMove, if _, err := tx.ExecContext(ctx, insertMove,
record.Move.GameID, record.Move.GameID,
record.Move.MoveNumber, record.Move.MoveNumber,
record.Move.MoveUCI, record.Move.MoveUCI,
@@ -80,33 +93,35 @@ func (s *Store) RecordMove(record MovePersistence) error {
return err return err
} }
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" {
claimColumn, nameColumn = "black_claimed_by", "black_name"
}
var set []string
args := []any{record.Move.GameID}
where := "game_id = $1"
if record.ClaimedBy != "" { if record.ClaimedBy != "" {
column := "white_claimed_by" args = append(args, record.ClaimedBy)
if record.ClaimColor == "b" { param := fmt.Sprintf("$%d", len(args))
column = "black_claimed_by" set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)",
} claimColumn, param, nameColumn, nameColumn, claimantName(param)))
query := `UPDATE games SET ` + column + ` = ? where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param)
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
}
if err := requireOneGame(result, record.Move.GameID); err != nil {
return err
}
} }
if record.Result != "" { if record.Result != "" {
result, err := tx.Exec( args = append(args, record.Result, record.EndTimeUTC)
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`, set = append(set, fmt.Sprintf("result = $%d, end_time_utc = $%d", len(args)-1, len(args)))
record.Result, record.EndTimeUTC, record.Move.GameID,
)
if err != nil {
return err
}
return requireOneGame(result, record.Move.GameID)
} }
return nil 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)
}) })
} }
@@ -122,56 +137,10 @@ func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
if at.IsZero() { if at.IsZero() {
return errors.New("game result time is required") return errors.New("game result time is required")
} }
return s.enqueue("record_game_result", gameID, func(tx *sql.Tx) error { return s.enqueue("record_game_result", gameID, func(ctx context.Context, tx *sql.Tx) error {
res, err := tx.Exec( res, err := tx.ExecContext(ctx,
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`, `UPDATE games SET result = $2, end_time_utc = $3 WHERE game_id = $1`,
result, at.UTC(), gameID, gameID, result, at.UTC(),
)
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,
) )
if err != nil { if err != nil {
return err return err
@@ -189,21 +158,44 @@ type PlayerRecord struct {
ClaimedBy string 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 // RewindGame atomically removes undone moves and clears a previously terminal
// result so replay readers never observe an ongoing line with a stale outcome. // result so replay readers never observe an ongoing line with a stale outcome.
func (s *Store) RewindGame(gameID string, afterMoveNumber int) error { func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
if gameID == "" || afterMoveNumber < 0 { if gameID == "" || afterMoveNumber < 0 {
return errors.New("game ID and a non-negative move number are required") return errors.New("game ID and a non-negative move number are required")
} }
return s.enqueue("rewind_game", gameID, func(tx *sql.Tx) error { return s.enqueue("rewind_game", gameID, func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.Exec( if _, err := tx.ExecContext(ctx,
`DELETE FROM moves WHERE game_id = ? AND move_number > ?`, `DELETE FROM moves WHERE game_id = $1 AND move_number > $2`,
gameID, afterMoveNumber, gameID, afterMoveNumber,
); err != nil { ); err != nil {
return err return err
} }
res, err := tx.Exec( res, err := tx.ExecContext(ctx,
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = ?`, `UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = $1`,
gameID, gameID,
) )
if err != nil { 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 // DeleteAnonymousGames queues removal of games that no registered user
// both creation-time player IDs and claims made after game creation. // claimed and whose last activity (start, last move, or end) precedes cutoff.
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) { // Games still loaded in memory are listed in live and always kept, so a queued
if err := s.flushBeforeRead(); err != nil { // or future write can never target a deleted row. Moves cascade.
return nil, err func (s *Store) DeleteAnonymousGames(cutoff time.Time, live []string) error {
if cutoff.IsZero() {
return errors.New("anonymous game cutoff is required")
} }
started := time.Now() if live == nil {
query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE 1=1` live = []string{}
var args []any }
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 != "*" { if gameID != "" && gameID != "*" {
query += " AND g.game_id = ?" if !validUUID(gameID) {
return nil, fmt.Errorf("invalid game ID %q", gameID)
}
args = append(args, gameID) args = append(args, gameID)
query += fmt.Sprintf(" AND g.game_id = $%d", len(args))
} }
if playerID != "" && playerID != "*" { if playerID != "" && playerID != "*" {
query += ` AND (g.white_player_id = ? OR g.black_player_id = ? if !validUUID(playerID) {
OR g.white_claimed_by = ? OR g.black_claimed_by = ?)` return nil, fmt.Errorf("invalid player ID %q", playerID)
args = append(args, playerID, playerID, playerID, 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" 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 { if err != nil {
return nil, fmt.Errorf("query games: %w", err) 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 { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate games: %w", err) return nil, fmt.Errorf("iterate games: %w", err)
} }
slog.Debug("storage games queried", "count", len(games), "duration", time.Since(started))
return games, nil return games, nil
} }
func (s *Store) GetGameRecord(gameID string) (*GameRecord, error) { // GetGameHistory returns the game row and its ordered moves after every
if err := s.flushBeforeRead(); err != nil { // previously accepted write has committed. Both reads share one REPEATABLE READ
return nil, err // snapshot, so a concurrent move or rewind cannot produce a mixed history.
}
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.
func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error) { func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error) {
if !validUUID(gameID) {
return nil, nil, sql.ErrNoRows
}
if err := s.flushBeforeRead(); err != nil { if err := s.flushBeforeRead(); err != nil {
return nil, nil, err return nil, nil, err
} }
started := time.Now() 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 { if err != nil {
return nil, nil, fmt.Errorf("begin game history read: %w", err) return nil, nil, fmt.Errorf("begin game history read: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
record, err := getGameRecord(tx, gameID) var record GameRecord
if err != nil { 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 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 { 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 { if err := tx.Commit(); err != nil {
return nil, nil, fmt.Errorf("finish game history read: %w", err) return nil, nil, fmt.Errorf("finish game history read: %w", err)
} }
slog.Debug("storage game history queried", slog.Debug("storage game history queried",
"game_id", gameID, "move_count", len(moves), "duration", time.Since(started)) "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) { func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSummaryRecord, error) {
if userID == "" || limit < 1 || limit > 101 || offset < 0 { if userID == "" || limit < 1 || limit > MaxUserGamesPage || offset < 0 {
return nil, errors.New("user ID, limit from 1 to 101, and non-negative offset are required") 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 { if err := s.flushBeforeRead(); err != nil {
return nil, err return nil, err
} }
started := time.Now() started := time.Now()
query := `SELECT ` + gameSelectColumns + `, const query = `SELECT ` + gameSelectColumns + `,
(SELECT COUNT(*) FROM moves m WHERE m.game_id = g.game_id) AS move_count COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen)
FROM games g FROM games g
WHERE g.white_player_id = ? OR g.black_player_id = ? LEFT JOIN LATERAL (
OR g.white_claimed_by = ? OR g.black_claimed_by = ? 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 ORDER BY g.start_time_utc DESC, g.game_id DESC
LIMIT ? OFFSET ?` LIMIT $2 OFFSET $3`
rows, err := s.db.Query(query, userID, userID, userID, userID, limit, offset) ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx, query, userID, limit, offset)
if err != nil { if err != nil {
return nil, fmt.Errorf("query user games: %w", err) 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) games := make([]GameSummaryRecord, 0)
for rows.Next() { for rows.Next() {
var summary GameSummaryRecord 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) return nil, fmt.Errorf("scan user game: %w", err)
} }
games = append(games, summary) games = append(games, summary)
@@ -386,45 +400,28 @@ type rowScanner interface {
Scan(dest ...any) error Scan(dest ...any) error
} }
func scanGame(scanner rowScanner, record *GameRecord) error { // scanGame scans gameSelectColumns followed by any extra destinations.
var whiteClaimed, blackClaimed, result sql.NullString func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
var whiteClaimed, whiteName, blackClaimed, blackName, result sql.NullString
var endTime sql.NullTime var endTime sql.NullTime
if err := scanner.Scan( dest := []any{
&record.GameID, &record.InitialFEN, &record.GameID, &record.InitialFEN,
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, &whiteClaimed, &record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime,
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed, &whiteClaimed, &whiteName,
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
&blackClaimed, &blackName,
&result, &record.StartTimeUTC, &endTime, &result, &record.StartTimeUTC, &endTime,
); err != nil { }
if err := scanner.Scan(append(dest, extra...)...); err != nil {
return err return err
} }
record.WhiteClaimedBy = whiteClaimed.String record.WhiteClaimedBy = whiteClaimed.String
record.WhiteName = whiteName.String
record.BlackClaimedBy = blackClaimed.String record.BlackClaimedBy = blackClaimed.String
record.BlackName = blackName.String
record.Result = result.String record.Result = result.String
if endTime.Valid { record.StartTimeUTC = record.StartTimeUTC.UTC()
ended := endTime.Time record.EndTimeUTC = utcPointer(endTime)
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
}
return nil return nil
} }
@@ -439,13 +436,6 @@ func requireOneGame(result sql.Result, gameID string) error {
return nil return nil
} }
func nullableString(value string) any {
if value == "" {
return nil
}
return value
}
func isValidResult(result string) bool { func isValidResult(result string) bool {
switch result { switch result {
case "white_wins", "black_wins", "draw", "stalemate": case "white_wins", "black_wins", "draw", "stalemate":
+73
View File
@@ -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
}
+124 -95
View File
@@ -2,59 +2,62 @@ package storage
import "time" 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 { type UserRecord struct {
UserID string `db:"user_id"` UserID string
Username string `db:"username"` Username string
Email string `db:"email"` Email string // empty when the account has no email (stored as NULL)
PasswordHash string `db:"password_hash"` PasswordHash string
AccountType string `db:"account_type"` // "permanent" or "temp" CreatedAt time.Time
CreatedAt time.Time `db:"created_at"` LastLoginAt *time.Time
ExpiresAt *time.Time `db:"expires_at"` // nil for permanent
LastLoginAt *time.Time `db:"last_login_at"`
} }
// SessionRecord represents an active user session // SessionRecord represents an active user session
type SessionRecord struct { type SessionRecord struct {
SessionID string `db:"session_id"` SessionID string
UserID string `db:"user_id"` UserID string
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time
ExpiresAt time.Time `db:"expires_at"` ExpiresAt time.Time
} }
// GameRecord represents a row in the games table // GameRecord represents a row in the games table
type GameRecord struct { type GameRecord struct {
GameID string `db:"game_id"` GameID string
InitialFEN string `db:"initial_fen"` InitialFEN string
WhitePlayerID string `db:"white_player_id"` WhitePlayerID string
WhiteType int `db:"white_type"` WhiteType int
WhiteLevel int `db:"white_level"` WhiteLevel int
WhiteSearchTime int `db:"white_search_time"` WhiteSearchTime int
WhiteClaimedBy string `db:"white_claimed_by"` WhiteClaimedBy string
BlackPlayerID string `db:"black_player_id"` WhiteName string // claimant's username when the claim was recorded
BlackType int `db:"black_type"` BlackPlayerID string
BlackLevel int `db:"black_level"` BlackType int
BlackSearchTime int `db:"black_search_time"` BlackLevel int
BlackClaimedBy string `db:"black_claimed_by"` BlackSearchTime int
Result string `db:"result"` BlackClaimedBy string
StartTimeUTC time.Time `db:"start_time_utc"` BlackName string
EndTimeUTC *time.Time `db:"end_time_utc"` 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 { type GameSummaryRecord struct {
GameRecord GameRecord
MoveCount int `db:"move_count"` MoveCount int
FinalFEN string
} }
// MoveRecord represents a row in the moves table // MoveRecord represents a row in the moves table
type MoveRecord struct { type MoveRecord struct {
MoveID int64 `db:"move_id"` GameID string
GameID string `db:"game_id"` MoveNumber int
MoveNumber int `db:"move_number"` MoveUCI string
MoveUCI string `db:"move_uci"` FENAfterMove string
FENAfterMove string `db:"fen_after_move"` PlayerColor string
PlayerColor string `db:"player_color"` MoveTimeUTC time.Time
MoveTimeUTC time.Time `db:"move_time_utc"`
} }
// MovePersistence groups changes caused by one accepted move so the move, // MovePersistence groups changes caused by one accepted move so the move,
@@ -67,72 +70,98 @@ type MovePersistence struct {
EndTimeUTC *time.Time EndTimeUTC *time.Time
} }
// Schema defines tables only. Indexes are applied after legacy column // schemaVersion is the newest schema this binary understands. Migrations are
// migrations so upgrading an older games table never references a missing // applied in order inside one transaction; PostgreSQL DDL is transactional, so
// column. // a failed upgrade leaves the previous version intact.
const Schema = ` const schemaVersion = 1
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, // migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
username TEXT UNIQUE NOT NULL COLLATE NOCASE, // released migration; append a new one.
email TEXT COLLATE NOCASE, var migrations = []string{
password_hash TEXT NOT NULL, // v1: initial PostgreSQL schema.
account_type TEXT NOT NULL DEFAULT 'temp' CHECK(account_type IN ('permanent', 'temp')), //
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, // Identifiers are uuid (16 bytes, validated on input). Usernames and emails
expires_at DATETIME, // are stored lowercase, so plain unique constraints give case-insensitive
last_login_at DATETIME // 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 ( CREATE TABLE users (
session_id TEXT PRIMARY KEY, user_id uuid PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE, username text NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, email text,
expires_at DATETIME NOT NULL, password_hash text NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE 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 ( CREATE TABLE sessions (
game_id TEXT PRIMARY KEY, session_id uuid PRIMARY KEY,
initial_fen TEXT NOT NULL, user_id uuid NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
white_player_id TEXT NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
white_type INTEGER NOT NULL, expires_at timestamptz NOT NULL,
white_level INTEGER NOT NULL DEFAULT 0, CONSTRAINT sessions_user_id_key UNIQUE (user_id)
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 IF NOT EXISTS moves ( CREATE TABLE games (
move_id INTEGER PRIMARY KEY AUTOINCREMENT, game_id uuid PRIMARY KEY,
game_id TEXT NOT NULL, initial_fen text NOT NULL CHECK (initial_fen <> ''),
move_number INTEGER NOT NULL, white_player_id uuid NOT NULL,
move_uci TEXT NOT NULL, white_type smallint NOT NULL CHECK (white_type IN (1, 2)),
fen_after_move TEXT NOT NULL, white_level smallint NOT NULL DEFAULT 0 CHECK (white_level BETWEEN 0 AND 20),
player_color TEXT NOT NULL CHECK(player_color IN ('w', 'b')), white_search_time integer NOT NULL DEFAULT 0 CHECK (white_search_time >= 0),
move_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, white_claimed_by uuid,
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE, white_name text CHECK (char_length(white_name) BETWEEN 1 AND 64),
UNIQUE(game_id, move_number) 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 = ` -- Moves are numbered 1..n without gaps; the primary key serves ordered replay
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique -- reads and the last-move probe used by game listings.
ON users(email) WHERE email IS NOT NULL AND email != ''; CREATE TABLE moves (
CREATE INDEX IF NOT EXISTS idx_users_temp_created_at game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
ON users(created_at) WHERE account_type = 'temp'; move_number integer NOT NULL CHECK (move_number > 0),
CREATE INDEX IF NOT EXISTS idx_users_temp_expires_at move_uci text NOT NULL CHECK (move_uci ~ '^[a-h][1-8][a-h][1-8][qrbn]?$'),
ON users(expires_at) WHERE account_type = 'temp' AND expires_at IS NOT NULL; fen_after_move text NOT NULL CHECK (fen_after_move <> ''),
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); player_color text NOT NULL CHECK (player_color IN ('w', 'b')),
CREATE INDEX IF NOT EXISTS idx_games_white_player ON games(white_player_id); move_time_utc timestamptz NOT NULL DEFAULT now(),
CREATE INDEX IF NOT EXISTS idx_games_black_player ON games(black_player_id); PRIMARY KEY (game_id, move_number)
CREATE INDEX IF NOT EXISTS idx_games_white_claimed ON games(white_claimed_by) );
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; 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; 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"}
+50 -61
View File
@@ -1,20 +1,30 @@
package storage package storage
import ( import (
"database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "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 { func (s *Store) CreateSession(record SessionRecord) error {
const query = `INSERT INTO sessions (session_id, user_id, created_at, expires_at) ctx, cancel := opContext()
VALUES (?, ?, ?, ?) defer cancel()
ON CONFLICT(user_id) DO UPDATE SET const query = `WITH session AS (
session_id = excluded.session_id, INSERT INTO sessions (session_id, user_id, created_at, expires_at)
created_at = excluded.created_at, VALUES ($1, $2, $3, $4)
expires_at = excluded.expires_at` ON CONFLICT (user_id) DO UPDATE SET
if _, err := s.db.Exec(query, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil { session_id = excluded.session_id,
created_at = excluded.created_at,
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) return fmt.Errorf("failed to create session: %w", err)
} }
slog.Debug("storage session created", "user_id", record.UserID, "expires_at", record.ExpiresAt) 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 // GetSession retrieves a session by ID
func (s *Store) GetSession(sessionID string) (*SessionRecord, error) { func (s *Store) GetSession(sessionID string) (*SessionRecord, error) {
if !validUUID(sessionID) {
return nil, sql.ErrNoRows
}
ctx, cancel := opContext()
defer cancel()
var session SessionRecord var session SessionRecord
query := `SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE session_id = ?` err := s.db.QueryRowContext(ctx,
`SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE session_id = $1`,
err := s.db.QueryRow(query, sessionID).Scan( sessionID,
&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt, ).Scan(&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt)
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
session.CreatedAt = session.CreatedAt.UTC()
session.ExpiresAt = session.ExpiresAt.UTC()
return &session, nil return &session, nil
} }
// GetSessionByUserID retrieves the active session for a user // DeleteSession removes a session (logout). Deleting an absent session is not
func (s *Store) GetSessionByUserID(userID string) (*SessionRecord, error) { // an 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
func (s *Store) DeleteSession(sessionID string) error { func (s *Store) DeleteSession(sessionID string) error {
query := `DELETE FROM sessions WHERE session_id = ?` if !validUUID(sessionID) {
_, err := s.db.Exec(query, sessionID) return nil
if err == nil {
slog.Debug("storage session deleted")
} }
return err ctx, cancel := opContext()
} defer cancel()
if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE session_id = $1`, sessionID); err != nil {
// DeleteSessionByUserID removes all sessions for a user return err
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 slog.Debug("storage session deleted")
return nil
} }
// DeleteExpiredSessions removes expired sessions // DeleteExpiredSessions removes expired sessions
func (s *Store) DeleteExpiredSessions() (int64, error) { func (s *Store) DeleteExpiredSessions() (int64, error) {
query := `DELETE FROM sessions WHERE expires_at < ?` ctx, cancel := opContext()
result, err := s.db.Exec(query, time.Now().UTC()) defer cancel()
result, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < $1`, time.Now().UTC())
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -83,29 +81,20 @@ func (s *Store) DeleteExpiredSessions() (int64, error) {
return deleted, err 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 // IsSessionValidForUser verifies both expiry and the binding between a JWT
// subject and its persisted session. Checking only the session ID would allow // subject and its persisted session. Checking only the session ID would allow
// a malformed server-issued token to authenticate as the wrong subject. // a malformed server-issued token to authenticate as the wrong subject.
func (s *Store) IsSessionValidForUser(sessionID, userID string) (bool, error) { 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 var valid bool
const query = `SELECT EXISTS( err := s.db.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM sessions SELECT 1 FROM sessions
WHERE session_id = ? AND user_id = ? AND expires_at > ? WHERE session_id = $1 AND user_id = $2 AND expires_at > $3
)` )`, sessionID, userID, time.Now().UTC()).Scan(&valid)
err := s.db.QueryRow(query, sessionID, userID, time.Now().UTC()).Scan(&valid)
if err != nil { if err != nil {
return false, err return false, err
} }
+256 -173
View File
@@ -3,25 +3,43 @@ package storage
import ( import (
"context" "context"
"database/sql" "database/sql"
"database/sql/driver"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "net"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "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 ( const (
writeQueueCapacity = 1000 writeQueueCapacity = 1000
flushTimeout = 5 * time.Second 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 ( var (
ErrStorageDegraded = errors.New("storage is degraded") ErrStorageDegraded = errors.New("storage is degraded")
@@ -32,14 +50,14 @@ var (
type writeRequest struct { type writeRequest struct {
operation string operation string
gameID string gameID string
run func(*sql.Tx) error run func(context.Context, *sql.Tx) error
barrier chan 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 { type Store struct {
db *sql.DB db *sql.DB
path string
writeChan chan writeRequest writeChan chan writeRequest
healthStatus atomic.Bool healthStatus atomic.Bool
writeFailed atomic.Bool writeFailed atomic.Bool
@@ -52,68 +70,55 @@ type Store struct {
closeErr error closeErr error
} }
// NewStore creates a new storage instance with async writer // NewStore connects to PostgreSQL and starts the async writer. dsn is a
func NewStore(dataSourceName string, devMode bool) (*Store, error) { // libpq-style keyword/value string or postgres:// URL; unset fields fall back
dsn := sqliteDSN(dataSourceName) // to the standard PG* environment variables and ~/.pgpass. The schema is
db, err := sql.Open("sqlite3", dsn) // 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 { 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() db.Close()
return nil, fmt.Errorf("failed to connect to database: %w", err) return nil, fmt.Errorf("failed to connect to database: %w", err)
} }
// SQLite benefits from a small pool. WAL and busy_timeout are configured in writerCtx, writerCancel := context.WithCancel(context.Background())
// the DSN for every connection, unlike connection-local PRAGMA calls.
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
ctx, cancel := context.WithCancel(context.Background())
s := &Store{ s := &Store{
db: db, db: db,
path: dataSourceName,
writeChan: make(chan writeRequest, writeQueueCapacity), writeChan: make(chan writeRequest, writeQueueCapacity),
ctx: ctx, ctx: writerCtx,
cancel: cancel, cancel: writerCancel,
} }
// Initialize health as true
s.healthStatus.Store(true) s.healthStatus.Store(true)
// Start async writer
s.wg.Add(1) s.wg.Add(1)
go s.writerLoop() go s.writerLoop()
slog.Debug("storage opened", slog.Debug("storage opened",
"path", dataSourceName, "host", config.Host,
"dev_mode", devMode, "database", config.Database,
"user", config.User,
"write_queue_capacity", writeQueueCapacity, "write_queue_capacity", writeQueueCapacity,
"max_open_connections", 8, "max_open_connections", maxOpenConns,
) )
return s, nil 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 // IsHealthy returns true if the storage is operational
func (s *Store) IsHealthy() bool { func (s *Store) IsHealthy() bool {
return s.healthStatus.Load() 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 { func (s *Store) executeWrite(req writeRequest) error {
started := time.Now() started := time.Now()
tx, err := s.db.Begin() var err error
if err != nil { for attempt := 1; ; attempt++ {
s.writeFailed.Store(true) var committing bool
s.healthStatus.Store(false) committing, err = s.runWriteTx(req.run)
slog.Error("storage degraded: failed to begin transaction", if err == nil {
"operation", req.operation, "game_id", req.gameID, "error", err) slog.Debug("storage write committed",
return err "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)
} }
if err := req.run(tx); err != nil { s.writeFailed.Store(true)
rollbackErr := tx.Rollback() s.healthStatus.Store(false)
s.writeFailed.Store(true) slog.Error("storage degraded: write operation failed",
s.healthStatus.Store(false) "operation", req.operation, "game_id", req.gameID, "error", err)
slog.Error("storage degraded: write operation failed", return err
"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
}
slog.Debug("storage write committed",
"operation", req.operation,
"game_id", req.gameID,
"duration", time.Since(started),
"queue_depth", len(s.writeChan),
)
return nil
} }
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() s.enqueueMu.RLock()
defer s.enqueueMu.RUnlock() defer s.enqueueMu.RUnlock()
@@ -285,7 +304,7 @@ func (s *Store) flushBeforeRead() error {
return s.Flush(ctx) return s.Flush(ctx)
} }
// Close gracefully closes the database connection // Close drains accepted writes and closes the connection pool.
func (s *Store) Close() error { func (s *Store) Close() error {
s.closeOnce.Do(func() { s.closeOnce.Do(func() {
s.enqueueMu.Lock() s.enqueueMu.Lock()
@@ -301,125 +320,189 @@ func (s *Store) Close() error {
return s.closeErr 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 { func (s *Store) InitDB() error {
started := time.Now() 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 { if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err) return fmt.Errorf("failed to begin transaction: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
var currentVersion int if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, schemaLockKey); err != nil {
if err := tx.QueryRow("PRAGMA user_version").Scan(&currentVersion); err != nil { return fmt.Errorf("failed to lock schema: %w", err)
return fmt.Errorf("failed to read schema version: %w", err)
} }
if currentVersion > schemaVersion { current, err := readSchemaVersion(ctx, tx)
return fmt.Errorf( if err != nil {
"database schema version %d is newer than supported version %d", return err
currentVersion, }
schemaVersion, 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()
} }
if _, err := tx.Exec(Schema); err != nil { for version := current + 1; version <= schemaVersion; version++ {
return fmt.Errorf("failed to create schema: %w", err) if _, err := tx.ExecContext(ctx, migrations[version-1]); err != nil {
} return fmt.Errorf("failed to apply schema version %d: %w", version, 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 {
return err
} }
} }
if _, err := tx.ExecContext(ctx, `
// These indexes duplicate UNIQUE constraints or are superseded by targeted INSERT INTO schema_version (version) VALUES ($1)
// partial/composite indexes. Drop them during upgrades as well as omitting ON CONFLICT (singleton) DO UPDATE SET version = excluded.version, updated_at = now()`,
// them from new databases. schemaVersion,
for _, name := range []string{ ); err != nil {
"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)
}
}
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 {
return fmt.Errorf("failed to record schema version: %w", err) return fmt.Errorf("failed to record schema version: %w", err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit schema: %w", err) 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 return nil
} }
func ensureColumn(tx *sql.Tx, table, column, definition string) error { // SchemaVersion reports the version recorded in the database; 0 means no
rows, err := tx.Query("PRAGMA table_info(" + table + ")") // 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 { 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 func readSchemaVersion(ctx context.Context, tx *sql.Tx) (int, error) {
for rows.Next() { var exists bool
var cid int if err := tx.QueryRowContext(ctx,
var name, columnType string `SELECT to_regclass('schema_version') IS NOT NULL`,
var notNull, primaryKey int ).Scan(&exists); err != nil {
var defaultValue any return 0, fmt.Errorf("failed to inspect schema: %w", err)
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil { }
rows.Close() if !exists {
return fmt.Errorf("failed to inspect %s column: %w", table, err) return 0, nil
} }
if name == column { var version int
found = true err := tx.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&version)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("failed to read schema version: %w", err)
}
return version, nil
}
// 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)
}
return nil
}
// 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)
}
// 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 err := rows.Close(); err != nil { if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return fmt.Errorf("failed to close %s schema rows: %w", table, err) return false
} }
if err := rows.Err(); err != nil { var connectErr *pgconn.ConnectError
return fmt.Errorf("failed to inspect %s schema: %w", table, err) 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
} }
if found { 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 nil
} }
return value
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)
}
slog.Debug("storage schema column added", "table", table, "column", column)
return nil
} }
// DeleteDB removes the database file func utcPointer(value sql.NullTime) *time.Time {
func (s *Store) DeleteDB() error { if !value.Valid {
// Close connection first return nil
if err := s.Close(); err != nil {
return fmt.Errorf("failed to close database: %w", err)
} }
t := value.Time.UTC()
return &t
}
// ☣ DESTRUCTIVE: Removes database file // ErrSchemaMismatch reports a database whose schema is absent or at a version
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { // other than the one this binary supports.
return fmt.Errorf("failed to delete database file: %w", err) 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 return nil
} }
File diff suppressed because it is too large. Load diff
+122 -229
View File
@@ -5,76 +5,22 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"time"
) )
var ( var (
ErrUserAlreadyExists = errors.New("username or email already exists") ErrUserAlreadyExists = errors.New("username or email already exists")
ErrUserCapacity = errors.New("user capacity reached") 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 { type UserLimits struct {
MaxUsers int MaxUsers int // total accounts at which registration closes
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
} }
// CreateUser creates an administratively managed user without applying the // 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) return s.createUser(record, nil, nil)
} }
// CreateUserWithinLimits atomically applies registration limits, evicts the // CreateUserWithinLimits atomically applies the registration limit, creates
// oldest temporary account when required, creates the user, and optionally // the user, and optionally creates its initial session. A session failure
// creates its initial session. No account is evicted on a duplicate request, // rolls back the account.
// and a session failure rolls back the user and eviction together.
func (s *Store) CreateUserWithinLimits( func (s *Store) CreateUserWithinLimits(
record UserRecord, record UserRecord,
session *SessionRecord, session *SessionRecord,
@@ -96,69 +41,55 @@ func (s *Store) CreateUserWithinLimits(
} }
func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *UserLimits) error { 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 { if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err) return fmt.Errorf("failed to begin transaction: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
// Check uniqueness within transaction if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, userCreateLockKey); err != nil {
exists, err := s.userExists(tx, record.Username, record.Email) return fmt.Errorf("lock user creation: %w", err)
if err != nil { }
return 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 { if exists {
return ErrUserAlreadyExists return ErrUserAlreadyExists
} }
if limits != nil { if limits != nil && limits.MaxUsers > 0 {
var total, permanent int var total int
if err := tx.QueryRow(`SELECT COUNT(*), if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&total); err != nil {
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END)
FROM users`).Scan(&total, &permanent); err != nil {
return fmt.Errorf("count users: %w", err) return fmt.Errorf("count users: %w", err)
} }
if record.AccountType == "permanent" && permanent >= limits.PermanentSlots {
return ErrPermanentCapacity
}
if total >= limits.MaxUsers { if total >= limits.MaxUsers {
result, err := tx.Exec(`DELETE FROM users WHERE user_id = ( return ErrUserCapacity
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 if _, err := tx.ExecContext(ctx, `INSERT INTO users (
query := `INSERT INTO users ( user_id, username, email, password_hash, created_at
user_id, username, email, password_hash, account_type, created_at, expires_at ) VALUES ($1, lower($2), lower($3), $4, $5)`,
) VALUES (?, ?, ?, ?, ?, ?, ?)` record.UserID, record.Username, nullableString(record.Email),
record.PasswordHash, record.CreatedAt,
_, err = tx.Exec(query, ); err != nil {
record.UserID, record.Username, record.Email, return mapUserConflict(err)
record.PasswordHash, record.AccountType, record.CreatedAt, record.ExpiresAt,
)
if err != nil {
return err
} }
if session != nil { if session != nil {
if session.UserID != record.UserID { if _, err := tx.ExecContext(ctx,
return errors.New("initial session user does not match new user") `INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES ($1, $2, $3, $4)`,
}
if _, err := tx.Exec(
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
session.SessionID, session.UserID, session.CreatedAt, session.ExpiresAt, session.SessionID, session.UserID, session.CreatedAt, session.ExpiresAt,
); err != nil { ); err != nil {
return fmt.Errorf("create initial session: %w", err) 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 { if err := tx.Commit(); err != nil {
return err return err
} }
slog.Debug("storage user created", slog.Debug("storage user created", "user_id", record.UserID, "initial_session", session != nil)
"user_id", record.UserID,
"account_type", record.AccountType,
"initial_session", session != nil,
)
return nil return nil
} }
// DeleteUserByID removes a user by ID (synchronous, for replacement logic) // mapUserConflict converts a username/email uniqueness violation, including one
func (s *Store) DeleteUserByID(userID string) error { // lost to a concurrent writer, into ErrUserAlreadyExists.
query := `DELETE FROM users WHERE user_id = ?` func mapUserConflict(err error) error {
_, err := s.db.Exec(query, userID) if constraint, ok := uniqueViolation(err); ok &&
if err == nil { (constraint == "users_username_key" || constraint == "users_email_key") {
slog.Debug("storage user deleted", "user_id", userID) return ErrUserAlreadyExists
} }
return err return err
} }
// PromoteToPermament upgrades a temp user to permanent // DeleteUser removes a user synchronously; sessions cascade, while games keep
func (s *Store) PromoteToPermanent(userID string) error { // the claim ID and name snapshot. It returns sql.ErrNoRows when no such user
query := `UPDATE users SET account_type = 'permanent', expires_at = NULL WHERE user_id = ?` // exists.
_, err := s.db.Exec(query, userID) func (s *Store) DeleteUser(userID string) error {
return err if err := s.execUserUpdate(`DELETE FROM users WHERE user_id = $1`, userID); err != nil {
} 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)
} }
slog.Debug("storage user deleted", "user_id", userID)
err := tx.QueryRow(query, args...).Scan(&count) return nil
if err != nil {
return false, err
}
return count > 0, nil
} }
// UpdateUserPassword updates user password hash // UpdateUserPassword updates user password hash
func (s *Store) UpdateUserPassword(userID string, passwordHash string) error { func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
query := `UPDATE users SET password_hash = ? WHERE user_id = ?` return s.execUserUpdate(`UPDATE users SET password_hash = $2 WHERE user_id = $1`,
_, err := s.db.Exec(query, passwordHash, userID) userID, passwordHash)
return err
} }
// UpdateUserEmail updates user email // UpdateUserEmail updates the email; an empty value removes it.
func (s *Store) UpdateUserEmail(userID string, email string) error { func (s *Store) UpdateUserEmail(userID, email string) error {
query := `UPDATE users SET email = ? WHERE user_id = ?` return s.execUserUpdate(`UPDATE users SET email = lower($2) WHERE user_id = $1`,
_, err := s.db.Exec(query, email, userID) userID, nullableString(email))
return err
} }
// UpdateUserUsername updates username // UpdateUserUsername updates username
func (s *Store) UpdateUserUsername(userID string, username string) error { func (s *Store) UpdateUserUsername(userID, username string) error {
query := `UPDATE users SET username = ? WHERE user_id = ?` return s.execUserUpdate(`UPDATE users SET username = lower($2) WHERE user_id = $1`,
_, err := s.db.Exec(query, username, userID) userID, username)
return err
} }
// GetAllUsers retrieves all users // execUserUpdate runs a single-row statement keyed by user_id ($1).
func (s *Store) GetAllUsers() ([]UserRecord, error) { func (s *Store) execUserUpdate(query, userID string, args ...any) error {
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at if !validUUID(userID) {
FROM users ORDER BY created_at DESC` 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 { if err != nil {
return nil, err return nil, err
} }
@@ -248,91 +175,57 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
var users []UserRecord var users []UserRecord
for rows.Next() { for rows.Next() {
var user UserRecord var user UserRecord
var email sql.NullString if err := scanUser(rows, &user); err != nil {
err := rows.Scan(
&user.UserID, &user.Username, &email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err return nil, err
} }
user.Email = email.String
users = append(users, user) users = append(users, user)
} }
return users, rows.Err() return users, rows.Err()
} }
// UpdateUserLastLoginSync updates user last login time // GetUserByUsername retrieves a user by case-insensitive username.
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
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) { func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
var user UserRecord return s.getUser(`username = lower($1)`, username)
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
} }
// 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) { func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
var user UserRecord if email == "" {
var emailNull sql.NullString return nil, sql.ErrNoRows
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
} }
user.Email = emailNull.String return s.getUser(`email = lower($1)`, email)
return &user, nil
} }
// GetUserByID retrieves user by unique user ID // GetUserByID retrieves user by unique user ID
func (s *Store) GetUserByID(userID string) (*UserRecord, error) { func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
var user UserRecord if !validUUID(userID) {
var email sql.NullString return nil, sql.ErrNoRows
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at }
FROM users WHERE user_id = ?` return s.getUser(`user_id = $1`, userID)
}
err := s.db.QueryRow(query, userID).Scan( func (s *Store) getUser(predicate string, arg any) (*UserRecord, error) {
&user.UserID, &user.Username, &email, ctx, cancel := opContext()
&user.PasswordHash, &user.AccountType, &user.CreatedAt, defer cancel()
&user.ExpiresAt, &user.LastLoginAt, var user UserRecord
) row := s.db.QueryRowContext(ctx, `SELECT `+userSelectColumns+` FROM users WHERE `+predicate, arg)
if err != nil { if err := scanUser(row, &user); err != nil {
return nil, err return nil, err
} }
user.Email = email.String
return &user, nil return &user, nil
} }
// DeleteUser removes a user synchronously. Account operations are consistency func scanUser(scanner rowScanner, user *UserRecord) error {
// sensitive and should not be reported successful before SQLite commits them. var email sql.NullString
func (s *Store) DeleteUser(userID string) error { var lastLoginAt sql.NullTime
return s.DeleteUserByID(userID) 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
View File
@@ -6,9 +6,26 @@ This directory contains comprehensive test suites for the Chess API server, cove
- `jq` - JSON processor - `jq` - JSON processor
- `curl` - HTTP client - `curl` - HTTP client
- `sqlite3` - SQLite CLI (for database tests) - `psql` - PostgreSQL client (for database tests)
- `base64` - Base64 encoder (for JWT tests) - `base64` - Base64 encoder (for JWT tests)
- Stockfish in `PATH`
- Compiled `bin/chess-server` binary (or pass another path to the scripts) - 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 ## Running the test server
From repo root 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`. 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. The server runs with `-dev`, debug logging, HTTP request logging, and relaxed rate limiting.
Will clean up test database and temporary files, so it's preferred for clean testing. It drops the test tables on exit, so it's preferred for clean testing.
Can be used for all the tests. Can be used for all the tests.
Logging can be adjusted without editing the script: 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 | | API Functionality | `test-api.sh` | Game operations, moves, undo, rate limiting |
| Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence | | Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence |
| Long-Polling | `test-longpoll.sh` | Real-time updates, wait behavior, timeouts | | 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`) ## 1. API Functionality Tests (`test-api.sh`)
@@ -97,8 +114,8 @@ Tests user management, authentication, and persistence via API integration.
### Running the test ### Running the test
```bash ```bash
# Terminal 1: Start test server with database # Terminal 1: Start test server with database
# Server is running with -dev and persistent WAL storage # Server is running with -dev and PostgreSQL storage
test/test-db-server.sh bin/chess-server test/run-test-server.sh bin/chess-server
# Terminal 2: Run API integration tests # Terminal 2: Run API integration tests
test/test-db.sh bin/chess-server test/test-db.sh bin/chess-server
@@ -114,12 +131,12 @@ test/test-db.sh bin/chess-server
- **Database Schema**: Table creation, constraints, indexes - **Database Schema**: Table creation, constraints, indexes
### Test Flow ### 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) 2. Registers test users (alice, bob, charlie)
3. Tests authentication endpoints 3. Tests authentication endpoints
4. Validates JWT tokens and claims 4. Validates JWT tokens and claims
5. Tests duplicate user prevention 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`) ## 3. Long-Polling Tests (`test-longpoll.sh`)
@@ -129,7 +146,7 @@ Tests real-time game updates via HTTP long-polling.
```bash ```bash
# Terminal 1: Start server with storage # Terminal 1: Start server with storage
test/run-test-server.sh bin/chess-server 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 # Terminal 2: Run long-polling tests
test/test-longpoll.sh test/test-longpoll.sh
+15 -12
View File
@@ -4,7 +4,10 @@ set -e
# Configuration # Configuration
CHESS_SERVER_EXEC=${1:-"bin/chess-server"} 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" PID_FILE="/tmp/chess-server_test.pid"
API_PORT=${API_PORT:-8080} API_PORT=${API_PORT:-8080}
LOG_LEVEL=${LOG_LEVEL:-debug} LOG_LEVEL=${LOG_LEVEL:-debug}
@@ -41,9 +44,9 @@ cleanup() {
rm -f "$PID_FILE" rm -f "$PID_FILE"
fi fi
# Clean up database files # Drop test tables
echo "Removing test database files..." echo "Dropping test database tables..."
rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm" "$CHESS_SERVER_EXEC" db delete -confirm >/dev/null || true
echo -e "${GREEN}Cleanup complete${NC}" echo -e "${GREEN}Cleanup complete${NC}"
} }
@@ -51,13 +54,14 @@ cleanup() {
# Set up trap for cleanup on exit # Set up trap for cleanup on exit
trap cleanup EXIT SIGINT SIGTERM 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}" 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 # Initialize database
echo -e "${CYAN}Initializing test database...${NC}" echo -e "${CYAN}Initializing test database...${NC}"
"$CHESS_SERVER_EXEC" db init -path "$TEST_DB" "$CHESS_SERVER_EXEC" db init
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${RED}Failed to initialize database${NC}" echo -e "${RED}Failed to initialize database${NC}"
exit 1 exit 1
@@ -65,14 +69,14 @@ fi
# Add test users # Add test users
echo -e "${CYAN}Adding test users...${NC}" 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 -username alice -email alice@test.com -password AlicePass123
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${RED}Failed to create user alice${NC}" echo -e "${RED}Failed to create user alice${NC}"
exit 1 exit 1
fi fi
"$CHESS_SERVER_EXEC" db user add -path "$TEST_DB" \ "$CHESS_SERVER_EXEC" db user add \
-username bob -email bob@test.com -password BobSecure456 -username bob -email bob@test.com -password BobSecure456
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${RED}Failed to create user bob${NC}" echo -e "${RED}Failed to create user bob${NC}"
@@ -90,9 +94,9 @@ echo -e "${CYAN}╚════════════════════
echo "" echo ""
echo "Configuration:" echo "Configuration:"
echo " Executable: $CHESS_SERVER_EXEC" echo " Executable: $CHESS_SERVER_EXEC"
echo " Database: $TEST_DB" echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)"
echo " Port: $API_PORT" 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 " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
echo " Purpose: Backend for chess-server tests" echo " Purpose: Backend for chess-server tests"
echo " PID File: $PID_FILE" echo " PID File: $PID_FILE"
@@ -111,7 +115,6 @@ echo ""
-dev \ -dev \
-log-level "$LOG_LEVEL" \ -log-level "$LOG_LEVEL" \
-log-http="$LOG_HTTP" \ -log-http="$LOG_HTTP" \
-storage-path "$TEST_DB" \
-api-port "$API_PORT" \ -api-port "$API_PORT" \
-pid "$PID_FILE" \ -pid "$PID_FILE" \
-pid-lock -pid-lock
+21 -18
View File
@@ -8,8 +8,11 @@
BASE_URL="http://localhost:8080" BASE_URL="http://localhost:8080"
API_URL="${BASE_URL}/api/v1" API_URL="${BASE_URL}/api/v1"
TEST_DB="test.db"
CHESS_SERVER_EXEC=${1:-"bin/chess-server"} 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} API_DELAY=${API_DELAY:-50}
# Colors # Colors
@@ -121,7 +124,7 @@ api_request() {
} }
# Check dependencies # Check dependencies
for cmd in jq sqlite3 curl; do for cmd in jq psql curl; do
if ! command -v $cmd &> /dev/null; then if ! command -v $cmd &> /dev/null; then
echo -e "${RED}Error: $cmd is required but not installed${NC}" echo -e "${RED}Error: $cmd is required but not installed${NC}"
exit 1 exit 1
@@ -145,7 +148,7 @@ fi
print_header "Database & User Management Test Suite" print_header "Database & User Management Test Suite"
echo "Server: $BASE_URL" echo "Server: $BASE_URL"
echo "Executable: $CHESS_SERVER_EXEC" echo "Executable: $CHESS_SERVER_EXEC"
echo "Test Database (server-managed): $TEST_DB" echo "Test Database: \$CHESS_TEST_DSN (server-managed)"
echo "" echo ""
# ============================================================================== # ==============================================================================
@@ -153,11 +156,11 @@ print_header "SECTION 1: CLI User Operations"
# ============================================================================== # ==============================================================================
test_case "1.1: database initialization" 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) # Create testuser1 first (not charlie)
test_case "1.2: Add First User via CLI" 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) -email "testuser1@test.com" -password "TestPass123" 2>&1)
if echo "$OUTPUT" | grep -qi "User created successfully"; then if echo "$OUTPUT" | grep -qi "User created successfully"; then
echo -e "${GREEN} ✓ User created: testuser1${NC}" echo -e "${GREEN} ✓ User created: testuser1${NC}"
@@ -168,7 +171,7 @@ else
fi fi
test_case "1.3: Add Second User" 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) -password "TestPass456" 2>&1)
if echo "$OUTPUT" | grep -qi "User created successfully"; then if echo "$OUTPUT" | grep -qi "User created successfully"; then
echo -e "${GREEN} ✓ User created: testuser2${NC}" echo -e "${GREEN} ✓ User created: testuser2${NC}"
@@ -180,7 +183,7 @@ fi
# Now test duplicate prevention with an existing user # Now test duplicate prevention with an existing user
test_case "1.4: Duplicate Username Prevention" 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" "Duplicate username rejected"
test_case "1.5: Login with Case-Insensitive Username (ALICE)" test_case "1.5: Login with Case-Insensitive Username (ALICE)"
@@ -196,11 +199,11 @@ else
fi fi
test_case "1.6: Update User Email" 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" "Email update"
test_case "1.7: Update User Password" 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" "Password update"
test_case "2.1: Health Check" 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 if [ -n "$GAME_ID" ]; then
sleep 0.5 # Allow async write 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 if [ "$DB_WHITE_ID" = "$USER_ID_ALICE" ]; then
echo -e "${GREEN} ✓ User ID correctly persisted in database${NC}" echo -e "${GREEN} ✓ User ID correctly persisted in database${NC}"
((PASS++)) ((PASS++))
@@ -335,7 +338,7 @@ if [ -n "$GAME_ID" ]; then
fi fi
test_case "4.2: Query Games by User ID" 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 if [ "$GAMES_COUNT" -ge "2" ]; then
echo -e "${GREEN} ✓ User's games queryable: found $GAMES_COUNT games${NC}" echo -e "${GREEN} ✓ User's games queryable: found $GAMES_COUNT games${NC}"
((PASS++)) ((PASS++))
@@ -350,7 +353,7 @@ print_header "SECTION 5: Password Operations"
# Now TEST_PASS2_NEW is defined, this test should work # Now TEST_PASS2_NEW is defined, this test should work
test_case "5.1: Update User Password via CLI for 'bob'" 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'" "CLI password update for '$TEST_USER2'"
test_case "5.2: Login with NEW Password for 'bob'" 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'" 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" 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" "Add user '$TEST_USER_CLI' for hash test"
test_case "5.5: CLI rejects unsupported hash format" 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" "Unsupported bcrypt hash rejected by CLI"
# ============================================================================== # ==============================================================================
@@ -409,7 +412,7 @@ test_case "6.2: Concurrent Registration Handling"
wait wait
# Check only one user was created # 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 if [ "$USER_COUNT" = "1" ]; then
echo -e "${GREEN} ✓ Concurrent registration handled correctly${NC}" echo -e "${GREEN} ✓ Concurrent registration handled correctly${NC}"
((PASS++)) ((PASS++))
@@ -420,15 +423,15 @@ fi
test_case "6.3: Delete User" test_case "6.3: Delete User"
# First, get a user to delete # 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) -password "TempPass123" 2>&1)
TEMP_ID=$(echo "$OUTPUT" | grep "ID:" | awk '{print $2}') 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" "User deletion by username"
# Verify deletion # 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 if [ "$USER_EXISTS" = "0" ]; then
echo -e "${GREEN} ✓ User successfully deleted from database${NC}" echo -e "${GREEN} ✓ User successfully deleted from database${NC}"
((PASS++)) ((PASS++))