diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..66e8aa6 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/Makefile b/Makefile index 44a4408..ee2315e 100644 --- a/Makefile +++ b/Makefile @@ -10,8 +10,13 @@ GO := go GOROOT := $(shell go env GOROOT) GOFLAGS := -trimpath LDFLAGS := -s -w -CGO_SERVER := CGO_ENABLED=1 -CGO_CLIENT := CGO_ENABLED=0 +# Both binaries are pure Go (pgx has no C dependency), so builds are static and +# cross-compile without a C toolchain. +CGO := CGO_ENABLED=0 + +# PostgreSQL connection for run/db targets. Keyword/value or URL form; the +# default uses the local Unix socket and peer authentication. +CHESS_DSN ?= dbname=chess # Build info GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") @@ -40,22 +45,17 @@ all: build .PHONY: build build: server client -# Build server only +# Build server only. Phony: the Go build cache decides what is stale, so a +# changed source file is never masked by an existing binary. .PHONY: server -server: $(SERVER_BINARY) - -# Build server — CGO required for go-sqlite3 -$(SERVER_BINARY): $(BINARY_DIR) - $(CGO_SERVER) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE) +server: | $(BINARY_DIR) + $(CGO) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE) @echo "Built server: $(SERVER_BINARY)" # Build client only .PHONY: client -client: $(CLIENT_BINARY) - -# Build client — pure Go, no CGO -$(CLIENT_BINARY): $(BINARY_DIR) - $(CGO_CLIENT) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(CLIENT_BINARY) $(CLIENT_SOURCE) +client: | $(BINARY_DIR) + $(CGO) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(CLIENT_BINARY) $(CLIENT_SOURCE) @echo "Built client: $(CLIENT_BINARY)" # Create bin directory @@ -66,7 +66,7 @@ $(BINARY_DIR): .PHONY: wasm wasm: $(WASM_DIR) @echo "Building WASM client..." - $(CGO_CLIENT) GOOS=js GOARCH=wasm $(GO) build $(GOFLAGS) \ + $(CGO) GOOS=js GOARCH=wasm $(GO) build $(GOFLAGS) \ -ldflags "$(LDFLAGS)" \ -o $(WASM_BINARY) $(CLIENT_SOURCE) @cp "$(WASM_EXEC_SRC)" $(WASM_DIR)/ @@ -112,21 +112,28 @@ $(WASM_DIR): # Run server with default settings .PHONY: run-server run-server: server - $(SERVER_BINARY) -api-port 8080 -dev -storage-path db/chess.db + CHESS_DSN='$(CHESS_DSN)' $(SERVER_BINARY) -api-port 8080 -dev # Run server with web UI .PHONY: run-server-web run-server-web: server - $(SERVER_BINARY) -api-port 8080 -dev -storage-path db/chess.db -serve -web-port 9090 + CHESS_DSN='$(CHESS_DSN)' $(SERVER_BINARY) -api-port 8080 -dev -serve -web-port 9090 # Run client .PHONY: run-client run-client: client $(CLIENT_BINARY) -# Run tests (start server and run test scripts) +# Go unit and PostgreSQL integration tests. Database tests are skipped unless +# CHESS_TEST_DSN names a disposable database (see test/README.md). .PHONY: test -test: server +test: + $(GO) vet ./... + $(GO) test -race -count=1 ./... + +# Start a test server for the shell suites (requires CHESS_TEST_DSN) +.PHONY: test-server +test-server: server test/run-test-server.sh # Run individual test suites @@ -145,25 +152,25 @@ test-longpoll: # Database operations .PHONY: db-init db-init: server - $(SERVER_BINARY) db init -path db/chess.db + CHESS_DSN='$(CHESS_DSN)' $(SERVER_BINARY) db init .PHONY: db-clean -db-clean: - # ☣ DESTRUCTIVE: Removes database - rm -f db/chess.db db/chess.db-* +db-clean: server + # ☣ DESTRUCTIVE: drops every chess table in the DSN's search_path + CHESS_DSN='$(CHESS_DSN)' $(SERVER_BINARY) db delete -confirm -# Cross-compile server for FreeBSD from Linux (requires zig or freebsd cross toolchain) -# Usage: make server-freebsd-cross CC="zig cc -target x86_64-freebsd" -.PHONY: server-freebsd-cross -server-freebsd-cross: $(BINARY_DIR) - $(CGO_SERVER) GOOS=freebsd GOARCH=amd64 CC="$(CC)" \ $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BINARY) $(SERVER_SOURCE) - @echo "Built FreeBSD server (cross): $(SERVER_BINARY)" +# Cross-compile a static server for FreeBSD/amd64 (no C toolchain required) +.PHONY: server-freebsd +server-freebsd: | $(BINARY_DIR) + $(CGO) GOOS=freebsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" \ + -o $(SERVER_BINARY)-freebsd-amd64 $(SERVER_SOURCE) + @echo "Built FreeBSD server: $(SERVER_BINARY)-freebsd-amd64" -# Development build (with race detector) — native only, CGO required for server +# Development build with the race detector (the race runtime requires cgo) .PHONY: dev -dev: - $(CGO_SERVER) $(GO) build -race -o $(SERVER_BINARY) $(SERVER_SOURCE) - $(CGO_CLIENT) $(GO) build -race -o $(CLIENT_BINARY) $(CLIENT_SOURCE) +dev: | $(BINARY_DIR) + CGO_ENABLED=1 $(GO) build -race -o $(SERVER_BINARY) $(SERVER_SOURCE) + CGO_ENABLED=1 $(GO) build -race -o $(CLIENT_BINARY) $(CLIENT_SOURCE) @echo "Built with race detector enabled" # Clean build artifacts @@ -206,6 +213,7 @@ help: @echo " make client Build client only" @echo " make wasm Build WASM client" @echo " make wasm-full Build WASM with dependencies" + @echo " make server-freebsd Cross-compile static FreeBSD/amd64 server" @echo " make dev Build with race detector" @echo "" @echo "Run targets:" @@ -215,14 +223,15 @@ help: @echo " make wasm-serve Serve WASM client (port 8081)" @echo "" @echo "Test targets:" - @echo " make test Run all tests" + @echo " make test Run vet and Go tests (set CHESS_TEST_DSN for DB tests)" + @echo " make test-server Start the shell-suite test server (needs CHESS_TEST_DSN)" @echo " make test-api Run API tests" @echo " make test-db Run database tests" @echo " make test-longpoll Run long-poll tests" @echo "" - @echo "Database targets:" - @echo " make db-init Initialize database" - @echo " make db-clean Remove database (destructive)" + @echo "Database targets (CHESS_DSN=$(CHESS_DSN)):" + @echo " make db-init Create or migrate the schema" + @echo " make db-clean Drop all chess tables (destructive)" @echo "" @echo "WASM targets:" @echo " make wasm-deps Download xterm.js dependencies" diff --git a/README.md b/README.md index 7eaa0b1..040a5f4 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,13 @@ Go backend server providing a RESTful API for chess gameplay with user authentic - Custom FEN position support - Asynchronous engine move calculation - Configurable engine strength and thinking time -- SQLite persistence with async writes for games -- Durable game results, player claims, ordered move history, and replay API +- PostgreSQL 18 persistence with ordered async writes for games +- Durable game results, player claims and names, ordered move history, and replay API +- Games without a registered player are purged 24 hours after their last activity - Authenticated stored-game listing with bounded pagination - Configurable structured debug logs for persistence, cleanup, and engine work -- User management with secure Argon2 password storage +- User management with secure Argon2id password storage and scoped JWTs +- Static, CGO-free binaries (cross-compiles for FreeBSD) - PID file management for singleton enforcement - Database CLI for storage and user administration @@ -35,7 +37,7 @@ Go backend server providing a RESTful API for chess gameplay with user authentic - Go 1.26+ - Stockfish chess engine (`stockfish` in PATH) -- SQLite3 (for persistence features) +- PostgreSQL 18 (for persistence and accounts; see [Deployment](./doc/deployment.md)) ### Installation ```bash @@ -43,9 +45,13 @@ Go backend server providing a RESTful API for chess gameplay with user authentic yay -S stockfish # FreeBSD -pkg install stockfish +pkg install stockfish postgresql18-server ``` +The connection string comes from `-dsn` or `CHESS_DSN`, in libpq keyword/value +or URL form. With a local socket and peer authentication, `dbname=chess` is +enough. Unset fields fall back to the standard `PG*` variables and `~/.pgpass`. + ## Quick Start ### Using Make (Recommended) @@ -54,20 +60,22 @@ git clone https://github.com/lixenwraith/chess cd chess make build +export CHESS_DSN='dbname=chess' # used by make targets and the CLI + +# Create or migrate the schema (the server also does this at startup) +make db-init + # Standard mode with persistence and auth make run-server # Or run with web UI make run-server-web -# Initialize database with user support -make db-init - # View all build options make help # Add users via CLI -./bin/chess-server db user add -path db/chess.db -username alice -password AlicePass123 +./bin/chess-server db user add -username alice -password AlicePass123 ``` ### Building Manually @@ -78,53 +86,57 @@ cd chess go build ./cmd/chess-server # Standard mode with persistence and auth -./chess-server -storage-path chess.db +./chess-server -dsn 'dbname=chess' -jwt-secret-file ~/.chess-jwt.key # Development mode with all features -./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -pid-lock -api-port 9090 +./chess-server -dev -dsn 'dbname=chess' -pid /tmp/chess-server.pid -pid-lock -api-port 9090 # Detailed persistence and HTTP diagnostics -./chess-server -dev -storage-path chess.db -log-level debug -log-http=true +./chess-server -dev -dsn 'dbname=chess' -log-level debug -log-http=true -# Initialize database with user support -./chess-server db init -path chess.db +# Initialize the schema +./chess-server db init -dsn 'dbname=chess' -# Add users via CLI -./chess-server db user add -path chess.db -username alice -password AlicePass123 -./chess-server db user list -path chess.db +# Add users via CLI (-dsn defaults to $CHESS_DSN) +./chess-server db user add -dsn 'dbname=chess' -username alice -password AlicePass123 +./chess-server db user list -dsn 'dbname=chess' ``` Server listens on `http://localhost:8080`. See [API Reference](./doc/api.md) for endpoints including authentication. ## User Management -The chess server supports user accounts with secure authentication: +The chess server supports user accounts with secure authentication. The +commands below read the connection string from `CHESS_DSN` (or `-dsn`). +Accounts registered on the site and accounts created here are identical and +do not expire. Games without a registered player are deleted 24 hours after +their last activity. ### Creating Users ```bash # Add user with password -./chess-server db user add -path chess.db -username alice -email alice@example.com -password SecurePass123 +./chess-server db user add -username alice -email alice@example.com -password SecurePass123 # Interactive password prompt -./chess-server db user add -path chess.db -username bob -interactive +./chess-server db user add -username bob -interactive # Import with existing hash -./chess-server db user add -path chess.db -username charlie -hash '$argon2id$...' +./chess-server db user add -username charlie -hash '$argon2id$...' ``` ### Managing Users ```bash # List all users -./chess-server db user list -path chess.db +./chess-server db user list # Update password -./chess-server db user set-password -path chess.db -username alice -password NewPass456 +./chess-server db user set-password -username alice -password NewPass456 # Update email -./chess-server db user set-email -path chess.db -username alice -email newemail@example.com +./chess-server db user set-email -username alice -email newemail@example.com # Delete user -./chess-server db user delete -path chess.db -username alice +./chess-server db user delete -username alice ``` ## Web UI @@ -140,7 +152,7 @@ The chess server includes an embedded web UI for playing games through a browser ./chess-server -serve -web-port 3000 -web-host 0.0.0.0 # Full example with authentication enabled -./chess-server -dev -serve -web-port 9090 -api-port 8080 -storage-path chess.db +./chess-server -dev -serve -web-port 9090 -api-port 8080 -dsn 'dbname=chess' # Override the API origin seen by browsers when it differs from the listen address ./chess-server -serve -web-api-url https://api.example.com @@ -161,10 +173,11 @@ Access the UI at `http://localhost:9090` when server is running with `-serve` fl ## Documentation - [API Reference](./doc/api.md) - Endpoint specifications including auth +- [Deployment](./doc/deployment.md) - FreeBSD jail, PostgreSQL 18, service account, SQLite data import - [Architecture](./doc/architecture.md) - System design with auth layer - [Development](./doc/development.md) - Build, test, and user management - [Client Guide](./doc/client.md) - Interactive debugging client -- [Replay Implementation Tasks](./doc/todo.md) - Remaining CLI, web, archive, and production work +- [Replay Plan](./doc/todo.md) - Phased game-replay implementation and open decisions - [Stockfish Integration](./doc/stockfish.md) - Engine communication ## License diff --git a/cmd/chess-client-cli/exit_native.go b/cmd/chess-client-cli/exit_native.go index e06a7d4..1127fe4 100644 --- a/cmd/chess-client-cli/exit_native.go +++ b/cmd/chess-client-cli/exit_native.go @@ -9,4 +9,4 @@ import ( func handleExit() (restart bool) { display.Println(display.Cyan, "Goodbye!") return false -} \ No newline at end of file +} diff --git a/cmd/chess-client-cli/exit_wasm.go b/cmd/chess-client-cli/exit_wasm.go index cf6f6d5..ed3b03b 100644 --- a/cmd/chess-client-cli/exit_wasm.go +++ b/cmd/chess-client-cli/exit_wasm.go @@ -18,4 +18,4 @@ func handleExit() (restart bool) { display.Println(display.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") return true -} \ No newline at end of file +} diff --git a/cmd/chess-server/cli/cli.go b/cmd/chess-server/cli/cli.go index 6e54487..2f68992 100644 --- a/cmd/chess-server/cli/cli.go +++ b/cmd/chess-server/cli/cli.go @@ -18,6 +18,8 @@ import ( "golang.org/x/term" ) +const dsnEnv = "CHESS_DSN" + // Run is the entry point for the CLI mini-app func Run(args []string) error { if len(args) == 0 { @@ -41,74 +43,94 @@ func Run(args []string) error { } } -func runInit(args []string) error { - fs := flag.NewFlagSet("init", flag.ContinueOnError) - path := fs.String("path", "", "Database file path (required)") +// newFlagSet returns a flag set with the shared -dsn flag. An empty -dsn falls +// back to $CHESS_DSN (in openStore, so usage output never prints it), which +// keeps a password-bearing DSN out of process arguments. +func newFlagSet(name string) (*flag.FlagSet, *string) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + dsn := fs.String("dsn", "", "PostgreSQL connection string (default $"+dsnEnv+")") + return fs, dsn +} +// openStore connects and, unless migrating, verifies the schema is current. +func openStore(dsn string, requireSchema bool) (*storage.Store, error) { + if dsn == "" { + dsn = os.Getenv(dsnEnv) + } + if dsn == "" { + return nil, fmt.Errorf("database connection required: use -dsn or %s", dsnEnv) + } + store, err := storage.NewStore(dsn) + if err != nil { + return nil, err + } + if requireSchema { + if err := store.CheckSchema(); err != nil { + store.Close() + return nil, err + } + } + return store, nil +} + +func runInit(args []string) error { + fs, dsn := newFlagSet("init") if err := fs.Parse(args); err != nil { return err } - if *path == "" { - return fmt.Errorf("database path required") - } - - store, err := storage.NewStore(*path, false) + store, err := openStore(*dsn, false) if err != nil { - return fmt.Errorf("failed to create store: %w", err) + return err } defer store.Close() if err := store.InitDB(); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } - - fmt.Printf("Database initialized at: %s\n", *path) + version, err := store.SchemaVersion() + if err != nil { + return err + } + fmt.Printf("Database schema ready (version %d)\n", version) return nil } func runDelete(args []string) error { - fs := flag.NewFlagSet("delete", flag.ContinueOnError) - path := fs.String("path", "", "Database file path (required)") - + fs, dsn := newFlagSet("delete") + confirm := fs.Bool("confirm", false, "Confirm dropping all chess tables and data") if err := fs.Parse(args); err != nil { return err } - - if *path == "" { - return fmt.Errorf("database path required") + if !*confirm { + return fmt.Errorf("refusing to drop all chess tables without -confirm") } - store, err := storage.NewStore(*path, false) + store, err := openStore(*dsn, false) if err != nil { - return fmt.Errorf("failed to open store: %w", err) + return err } + defer store.Close() - if err := store.DeleteDB(); err != nil { - return fmt.Errorf("failed to delete database: %w", err) + // ☣ DESTRUCTIVE: drops every chess table in the connection's search_path + if err := store.DropSchema(); err != nil { + return err } - - fmt.Printf("Database deleted: %s\n", *path) + fmt.Println("Chess tables dropped") return nil } func runQuery(args []string) error { - fs := flag.NewFlagSet("query", flag.ContinueOnError) - path := fs.String("path", "", "Database file path (required)") + fs, dsn := newFlagSet("query") gameID := fs.String("gameId", "", "Game ID to filter (optional, * for all)") - playerID := fs.String("playerId", "", "Player ID to filter (optional, * for all)") - + playerID := fs.String("playerId", "", "Player or user ID to filter (optional, * for all)") if err := fs.Parse(args); err != nil { return err } - if *path == "" { - return fmt.Errorf("database path required") - } - - store, err := storage.NewStore(*path, false) + store, err := openStore(*dsn, true) if err != nil { - return fmt.Errorf("failed to open store: %w", err) + return err } defer store.Close() @@ -189,107 +211,86 @@ func runUser(subcommand string, args []string) error { } } +// readPassword prompts on the terminal without echo. +func readPassword(prompt string) (string, error) { + fmt.Print(prompt) + pwBytes, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", fmt.Errorf("failed to read password: %w", err) + } + return string(pwBytes), nil +} + +func hashPassword(password string) (string, error) { + if len(password) < 8 { + return "", fmt.Errorf("password must be at least 8 characters") + } + hash, err := auth.HashPassword(password) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + return hash, nil +} + func runUserAdd(args []string) error { - fs := flag.NewFlagSet("user add", flag.ContinueOnError) - path := fs.String("path", "", "Database file path (required)") + fs, dsn := newFlagSet("user add") username := fs.String("username", "", "Username (required)") email := fs.String("email", "", "Email address (optional)") password := fs.String("password", "", "Password (optional, will prompt if not provided)") hash := fs.String("hash", "", "Pre-computed password hash (optional)") interactive := fs.Bool("interactive", false, "Interactive password prompt") - temp := fs.Bool("temp", false, "Create as temporary user (24h TTL, default: permanent)") if err := fs.Parse(args); err != nil { return err } - - if *path == "" { - return fmt.Errorf("database path required") - } if *username == "" { return fmt.Errorf("username required") } - - // Validate password/hash options if *password != "" && *hash != "" { return fmt.Errorf("cannot specify both -password and -hash") } var passwordHash string - - if *interactive { + switch { + case *interactive: if *password != "" || *hash != "" { return fmt.Errorf("cannot use -interactive with -password or -hash") } - fmt.Print("Enter password: ") - pwBytes, err := term.ReadPassword(syscall.Stdin) - fmt.Println() + entered, err := readPassword("Enter password: ") if err != nil { - return fmt.Errorf("failed to read password: %w", err) + return err } - if len(pwBytes) < 8 { - return fmt.Errorf("password must be at least 8 characters") + if passwordHash, err = hashPassword(entered); err != nil { + return err } - - // Hash password (Argon2) - passwordHash, err = auth.HashPassword(string(pwBytes)) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) + case *hash != "": + if err := auth.ValidatePHCHashFormat(*hash); err != nil { + return fmt.Errorf("invalid hash format: %w", err) } - } else if *hash != "" { passwordHash = *hash - } else if *password != "" { - if len(*password) < 8 { - return fmt.Errorf("password must be at least 8 characters") - } - // Hash password (Argon2) + case *password != "": var err error - passwordHash, err = auth.HashPassword(*password) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) + if passwordHash, err = hashPassword(*password); err != nil { + return err } - } else { + default: return fmt.Errorf("password required: use -password, -hash, or -interactive") } - store, err := storage.NewStore(*path, false) + store, err := openStore(*dsn, true) if err != nil { - return fmt.Errorf("failed to open store: %w", err) + return err } defer store.Close() - // Generate user ID with conflict check - var userID string - for attempts := 0; attempts < 10; attempts++ { - userID = uuid.New().String() - if _, err := store.GetUserByID(userID); errors.Is(err, sql.ErrNoRows) { - // User doesn't exist, ID is unique - break - } else if err != nil { - return fmt.Errorf("failed to check generated user ID: %w", err) - } - if attempts == 9 { - return fmt.Errorf("failed to generate unique user ID after 10 attempts") - } - } - - // Determine account type (CLI default = permanent) - accountType := "permanent" - var expiresAt *time.Time - if *temp { - accountType = "temp" - expiry := time.Now().UTC().Add(24 * time.Hour) - expiresAt = &expiry - } - + userID := uuid.NewString() record := storage.UserRecord{ UserID: userID, Username: strings.ToLower(*username), Email: strings.ToLower(*email), PasswordHash: passwordHash, - AccountType: accountType, CreatedAt: time.Now().UTC(), - ExpiresAt: expiresAt, } if err := store.CreateUser(record); err != nil { @@ -298,50 +299,59 @@ func runUserAdd(args []string) error { fmt.Printf("User created successfully:\n") fmt.Printf(" ID: %s\n", userID) - fmt.Printf(" Username: %s\n", *username) - if *email != "" { - fmt.Printf(" Email: %s\n", *email) + fmt.Printf(" Username: %s\n", record.Username) + if record.Email != "" { + fmt.Printf(" Email: %s\n", record.Email) } return nil } +// lookupUser resolves -username or -id to a user ID. +func lookupUser(store *storage.Store, username, userID string) (string, error) { + switch { + case username == "" && userID == "": + return "", fmt.Errorf("either -username or -id required") + case username != "" && userID != "": + return "", fmt.Errorf("specify either -username or -id, not both") + case userID != "": + if _, err := store.GetUserByID(userID); err != nil { + return "", userLookupError(userID, err) + } + return userID, nil + default: + user, err := store.GetUserByUsername(username) + if err != nil { + return "", userLookupError(username, err) + } + return user.UserID, nil + } +} + +func userLookupError(identifier string, err error) error { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("user not found: %s", identifier) + } + return fmt.Errorf("failed to look up user %s: %w", identifier, err) +} + func runUserDelete(args []string) error { - fs := flag.NewFlagSet("user delete", flag.ContinueOnError) - path := fs.String("path", "", "Database file path (required)") + fs, dsn := newFlagSet("user delete") username := fs.String("username", "", "Username to delete") userID := fs.String("id", "", "User ID to delete") - if err := fs.Parse(args); err != nil { return err } - if *path == "" { - return fmt.Errorf("database path required") - } - if *username == "" && *userID == "" { - return fmt.Errorf("either -username or -id required") - } - if *username != "" && *userID != "" { - return fmt.Errorf("specify either -username or -id, not both") - } - - store, err := storage.NewStore(*path, false) + store, err := openStore(*dsn, true) if err != nil { - return fmt.Errorf("failed to open store: %w", err) + return err } defer store.Close() - var targetID string - if *userID != "" { - targetID = *userID - } else { - user, err := store.GetUserByUsername(*username) - if err != nil { - return fmt.Errorf("user not found: %s", *username) - } - targetID = user.UserID + targetID, err := lookupUser(store, *username, *userID) + if err != nil { + return err } - if err := store.DeleteUser(targetID); err != nil { return fmt.Errorf("failed to delete user: %w", err) } @@ -351,65 +361,49 @@ func runUserDelete(args []string) error { } func runUserSetPassword(args []string) error { - fs := flag.NewFlagSet("user set-password", flag.ContinueOnError) - path := fs.String("path", "", "Database file path (required)") + fs, dsn := newFlagSet("user set-password") username := fs.String("username", "", "Username (required)") password := fs.String("password", "", "New password") interactive := fs.Bool("interactive", false, "Interactive password prompt") - if err := fs.Parse(args); err != nil { return err } - - if *path == "" { - return fmt.Errorf("database path required") - } if *username == "" { return fmt.Errorf("username required") } var newPassword string - if *interactive { + switch { + case *interactive: if *password != "" { return fmt.Errorf("cannot use -interactive with -password") } - fmt.Print("Enter new password: ") - pwBytes, err := term.ReadPassword(int(syscall.Stdin)) - fmt.Println() + entered, err := readPassword("Enter new password: ") if err != nil { - return fmt.Errorf("failed to read password: %w", err) + return err } - newPassword = string(pwBytes) - } else if *password != "" { + newPassword = entered + case *password != "": newPassword = *password - } else { + default: return fmt.Errorf("password required: use -password or -interactive") } - - if len(newPassword) < 8 { - return fmt.Errorf("password must be at least 8 characters") + passwordHash, err := hashPassword(newPassword) + if err != nil { + return err } - store, err := storage.NewStore(*path, false) + store, err := openStore(*dsn, true) if err != nil { - return fmt.Errorf("failed to open store: %w", err) + return err } defer store.Close() - // Get user - user, err := store.GetUserByUsername(*username) + targetID, err := lookupUser(store, *username, "") if err != nil { - return fmt.Errorf("user not found: %s", *username) + return err } - - // Hash password (Argon2) - passwordHash, err := auth.HashPassword(newPassword) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - // Update password - if err := store.UpdateUserPassword(user.UserID, passwordHash); err != nil { + if err := store.UpdateUserPassword(targetID, passwordHash); err != nil { return fmt.Errorf("failed to update password: %w", err) } @@ -418,43 +412,33 @@ func runUserSetPassword(args []string) error { } func runUserSetHash(args []string) error { - fs := flag.NewFlagSet("user set-hash", flag.ContinueOnError) - path := fs.String("path", "", "Database file path (required)") + fs, dsn := newFlagSet("user set-hash") username := fs.String("username", "", "Username (required)") hash := fs.String("hash", "", "Password hash (required)") - if err := fs.Parse(args); err != nil { return err } - - if *path == "" { - return fmt.Errorf("database path required") - } if *username == "" { return fmt.Errorf("username required") } if *hash == "" { return fmt.Errorf("password hash required") } - if err := auth.ValidatePHCHashFormat(*hash); err != nil { return fmt.Errorf("invalid hash format: %w", err) } - store, err := storage.NewStore(*path, false) + store, err := openStore(*dsn, true) if err != nil { - return fmt.Errorf("failed to open store: %w", err) + return err } defer store.Close() - // Get user - user, err := store.GetUserByUsername(*username) + targetID, err := lookupUser(store, *username, "") if err != nil { - return fmt.Errorf("user not found: %s", *username) + return err } - - // Update password hash directly - if err := store.UpdateUserPassword(user.UserID, *hash); err != nil { + if err := store.UpdateUserPassword(targetID, *hash); err != nil { return fmt.Errorf("failed to update password hash: %w", err) } @@ -463,18 +447,12 @@ func runUserSetHash(args []string) error { } func runUserSetEmail(args []string) error { - fs := flag.NewFlagSet("user set-email", flag.ContinueOnError) - path := fs.String("path", "", "Database file path (required)") + fs, dsn := newFlagSet("user set-email") username := fs.String("username", "", "Username (required)") email := fs.String("email", "", "New email address (required)") - if err := fs.Parse(args); err != nil { return err } - - if *path == "" { - return fmt.Errorf("database path required") - } if *username == "" { return fmt.Errorf("username required") } @@ -482,20 +460,17 @@ func runUserSetEmail(args []string) error { return fmt.Errorf("email required") } - store, err := storage.NewStore(*path, false) + store, err := openStore(*dsn, true) if err != nil { - return fmt.Errorf("failed to open store: %w", err) + return err } defer store.Close() - // Get user - user, err := store.GetUserByUsername(*username) + targetID, err := lookupUser(store, *username, "") if err != nil { - return fmt.Errorf("user not found: %s", *username) + return err } - - // Update email - if err := store.UpdateUserEmail(user.UserID, strings.ToLower(*email)); err != nil { + if err := store.UpdateUserEmail(targetID, *email); err != nil { return fmt.Errorf("failed to update email: %w", err) } @@ -504,61 +479,46 @@ func runUserSetEmail(args []string) error { } func runUserSetUsername(args []string) error { - fs := flag.NewFlagSet("user set-username", flag.ContinueOnError) - path := fs.String("path", "", "Database file path (required)") + fs, dsn := newFlagSet("user set-username") current := fs.String("current", "", "Current username (required)") - new := fs.String("new", "", "New username (required)") - + newName := fs.String("new", "", "New username (required)") if err := fs.Parse(args); err != nil { return err } - - if *path == "" { - return fmt.Errorf("database path required") - } if *current == "" { return fmt.Errorf("current username required") } - if *new == "" { + if *newName == "" { return fmt.Errorf("new username required") } - store, err := storage.NewStore(*path, false) + store, err := openStore(*dsn, true) if err != nil { - return fmt.Errorf("failed to open store: %w", err) + return err } defer store.Close() - // Get user - user, err := store.GetUserByUsername(*current) + targetID, err := lookupUser(store, *current, "") if err != nil { - return fmt.Errorf("user not found: %s", *current) + return err } - - // Update username - if err := store.UpdateUserUsername(user.UserID, strings.ToLower(*new)); err != nil { + if err := store.UpdateUserUsername(targetID, *newName); err != nil { return fmt.Errorf("failed to update username: %w", err) } - fmt.Printf("Username updated: %s -> %s\n", *current, *new) + fmt.Printf("Username updated: %s -> %s\n", *current, strings.ToLower(*newName)) return nil } func runUserList(args []string) error { - fs := flag.NewFlagSet("user list", flag.ContinueOnError) - path := fs.String("path", "", "Database file path (required)") - + fs, dsn := newFlagSet("user list") if err := fs.Parse(args); err != nil { return err } - if *path == "" { - return fmt.Errorf("database path required") - } - - store, err := storage.NewStore(*path, false) + store, err := openStore(*dsn, true) if err != nil { - return fmt.Errorf("failed to open store: %w", err) + return err } defer store.Close() @@ -574,8 +534,8 @@ func runUserList(args []string) error { // Print results in tabular format w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "User ID\tUsername\tType\tEmail\tCreated\tExpires\tLast Login") - fmt.Fprintln(w, strings.Repeat("-", 120)) + fmt.Fprintln(w, "User ID\tUsername\tEmail\tCreated\tLast Login") + fmt.Fprintln(w, strings.Repeat("-", 100)) for _, u := range users { lastLogin := "never" @@ -586,17 +546,11 @@ func runUserList(args []string) error { if email == "" { email = "(none)" } - expires := "never" - if u.ExpiresAt != nil { - expires = u.ExpiresAt.Format("2006-01-02 15:04") - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", abbreviateID(u.UserID), u.Username, - u.AccountType, email, u.CreatedAt.Format("2006-01-02 15:04"), - expires, lastLogin, ) } diff --git a/cmd/chess-server/main.go b/cmd/chess-server/main.go index b47ca22..355bd39 100644 --- a/cmd/chess-server/main.go +++ b/cmd/chess-server/main.go @@ -41,12 +41,17 @@ func main() { apiHost = flag.String("api-host", "localhost", "API server host") apiPort = flag.Int("api-port", 8080, "API server port") dev = flag.Bool("dev", false, "Development mode (relaxed rate limits)") - storagePath = flag.String("storage-path", "", "Path to SQLite database file (disables persistence if empty)") + dsn = flag.String("dsn", "", "PostgreSQL connection string (default $CHESS_DSN; persistence is disabled if empty)") pidPath = flag.String("pid", "", "Optional path to write PID file") pidLock = flag.Bool("pid-lock", false, "Lock PID file to allow only one instance (requires -pid)") logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, or error") logHTTP = flag.Bool("log-http", true, "Log HTTP requests") + proxies = flag.String("trusted-proxies", "", "Comma-separated reverse-proxy IPs/CIDRs whose -proxy-header is trusted for client IPs") + proxyHeader = flag.String("proxy-header", "X-Real-IP", "Header carrying the client IP from a trusted proxy") finishedTTL = flag.Duration("finished-game-ttl", service.FinishedGameTTL, "How long completed games remain in memory (0 disables eviction)") + anonTTL = flag.Duration("anonymous-game-ttl", service.AnonymousGameTTL, "How long games without a registered player survive after their last activity, in memory and in the database (0 keeps them)") + maxUsers = flag.Int("max-users", service.DefaultMaxUsers, "Accounts at which public registration closes (0 = no limit; CLI-created accounts are not limited)") + jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)") // Web UI server flags serve = flag.Bool("serve", false, "Enable web UI server") @@ -55,6 +60,14 @@ func main() { webAPIURL = flag.String("web-api-url", "", "Browser-visible API base URL (defaults to the API listen address)") ) flag.Parse() + // Environment fallbacks are applied after parsing so -h never prints a + // DSN, which may contain a password. + if *dsn == "" { + *dsn = os.Getenv("CHESS_DSN") + } + if *jwtFile == "" { + *jwtFile = os.Getenv("CHESS_JWT_SECRET_FILE") + } var level slog.Level if err := level.UnmarshalText([]byte(*logLevel)); err != nil { @@ -73,6 +86,11 @@ func main() { // handler. Avoid embedding a second timestamp inside its message. log.SetFlags(0) + trustedProxies, err := parseTrustedProxies(*proxies) + if err != nil { + log.Fatalf("Invalid -trusted-proxies: %v", err) + } + // Validate PID flags if *pidLock && *pidPath == "" { log.Fatal("Error: -pid-lock flag requires the -pid flag to be set") @@ -88,12 +106,13 @@ func main() { log.Printf("PID file created at: %s (lock: %v)", *pidPath, *pidLock) } - // 1. Initialize Storage (optional) + // 1. Initialize Storage (optional). The DSN is never logged: it may carry a + // password when peer or .pgpass authentication is not used. var store *storage.Store - if *storagePath != "" { - log.Printf("Initializing persistent storage at: %s", *storagePath) + if *dsn != "" { + log.Printf("Initializing PostgreSQL storage") var err error - store, err = storage.NewStore(*storagePath, *dev) + store, err = storage.NewStore(*dsn) if err != nil { log.Fatalf("Failed to initialize storage: %v", err) } @@ -101,27 +120,37 @@ func main() { log.Fatalf("Failed to initialize schema: %v", err) } } else { - log.Printf("Persistent storage disabled (use -storage-path to enable)") + log.Printf("Persistent storage disabled (use -dsn or CHESS_DSN to enable)") } - // JWT secret management + // JWT signing key: a key file keeps sessions valid across restarts; without + // one, dev mode uses a fixed key and production generates a per-process key. var jwtSecret []byte - if *dev { - // Fixed secret in dev mode for testing consistency + switch { + case *jwtFile != "": + var err error + if jwtSecret, err = loadJWTSecret(*jwtFile); err != nil { + log.Fatalf("Failed to load JWT secret: %v", err) + } + log.Printf("JWT secret loaded from file (sessions survive restarts)") + case *dev: jwtSecret = []byte("dev-secret-minimum-32-characters-long") log.Printf("Using fixed JWT secret (dev mode)") - } else { - // Generate cryptographically secure secret + default: jwtSecret = make([]byte, 32) - if _, err := rand.Read(jwtSecret); err != nil { - log.Fatalf("Failed to generate JWT secret: %v", err) - } - log.Printf("JWT secret generated (sessions valid until restart)") + rand.Read(jwtSecret) + log.Printf("JWT secret generated (sessions valid until restart; use -jwt-secret-file to persist)") } // 2. Initialize the Service with optional storage and auth - svc := service.New(store, jwtSecret) + svc, err := service.New(store, jwtSecret) + clear(jwtSecret) // the JWT manager keeps its own copy + if err != nil { + log.Fatalf("Failed to initialize service: %v", err) + } svc.SetFinishedGameTTL(*finishedTTL) + svc.SetAnonymousGameTTL(*anonTTL) + svc.SetMaxUsers(*maxUsers) // Start cleanup job for expired users/sessions cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) @@ -141,7 +170,12 @@ func main() { } // 4. Initialize the Fiber App/HTTP Handler, injecting processor and service - app := http.NewFiberApp(proc, svc, *dev, *logHTTP) + app := http.NewFiberApp(proc, svc, http.Options{ + DevMode: *dev, + LogRequests: *logHTTP, + TrustedProxies: trustedProxies, + ProxyHeader: *proxyHeader, + }) // API Server configuration apiAddr := fmt.Sprintf("%s:%d", *apiHost, *apiPort) @@ -157,8 +191,13 @@ func main() { } else { log.Printf("Rate Limit: 10 requests/second per IP") } - if *storagePath != "" { - log.Printf("Storage: Enabled (%s)", *storagePath) + if len(trustedProxies) > 0 { + log.Printf("Client IP: %s from trusted proxies %v", *proxyHeader, trustedProxies) + } else { + log.Printf("Client IP: TCP peer (behind a reverse proxy, set -trusted-proxies or all clients share one rate limit)") + } + if store != nil { + log.Printf("Storage: Enabled (PostgreSQL)") } else { log.Printf("Storage: Disabled (auth features unavailable)") } diff --git a/cmd/chess-server/pid.go b/cmd/chess-server/pid.go index c5e8fbf..736ac67 100644 --- a/cmd/chess-server/pid.go +++ b/cmd/chess-server/pid.go @@ -102,4 +102,4 @@ func checkStalePID(path string) error { // Process is running return fmt.Errorf("stale PID file: process %d is running but not holding lock", pid) -} \ No newline at end of file +} diff --git a/cmd/chess-server/proxy.go b/cmd/chess-server/proxy.go new file mode 100644 index 0000000..7139c1a --- /dev/null +++ b/cmd/chess-server/proxy.go @@ -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 +} diff --git a/cmd/chess-server/proxy_test.go b/cmd/chess-server/proxy_test.go new file mode 100644 index 0000000..9a8a0bd --- /dev/null +++ b/cmd/chess-server/proxy_test.go @@ -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) + } + } +} diff --git a/cmd/chess-server/secret.go b/cmd/chess-server/secret.go new file mode 100644 index 0000000..64f88e7 --- /dev/null +++ b/cmd/chess-server/secret.go @@ -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 +} diff --git a/cmd/chess-server/secret_test.go b/cmd/chess-server/secret_test.go new file mode 100644 index 0000000..53ffc84 --- /dev/null +++ b/cmd/chess-server/secret_test.go @@ -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") + } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..0249006 --- /dev/null +++ b/deploy/README.md @@ -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 ` | `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 diff --git a/deploy/freebsd/chess-backup.sh b/deploy/freebsd/chess-backup.sh new file mode 100755 index 0000000..c0152c7 --- /dev/null +++ b/deploy/freebsd/chess-backup.sh @@ -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 ' +# (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 diff --git a/deploy/freebsd/host.sh b/deploy/freebsd/host.sh new file mode 100755 index 0000000..3fbe842 --- /dev/null +++ b/deploy/freebsd/host.sh @@ -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 +# +# 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 " >&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" diff --git a/deploy/freebsd/nginx-chess.conf b/deploy/freebsd/nginx-chess.conf new file mode 100644 index 0000000..0cb79e6 --- /dev/null +++ b/deploy/freebsd/nginx-chess.conf @@ -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; +} diff --git a/deploy/freebsd/rc.d/chess_server b/deploy/freebsd/rc.d/chess_server new file mode 100755 index 0000000..a98ffe6 --- /dev/null +++ b/deploy/freebsd/rc.d/chess_server @@ -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" diff --git a/deploy/freebsd/setup-jail.sh b/deploy/freebsd/setup-jail.sh new file mode 100644 index 0000000..b9619ad --- /dev/null +++ b/deploy/freebsd/setup-jail.sh @@ -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 (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 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 <&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 diff --git a/deploy/postgresql/setup.sql b/deploy/postgresql/setup.sql new file mode 100644 index 0000000..3cdc8c1 --- /dev/null +++ b/deploy/postgresql/setup.sql @@ -0,0 +1,63 @@ +-- Provision the chess database on PostgreSQL 18. Run once as a superuser: +-- +-- psql -X -U postgres -d postgres -f setup.sql # owner mode +-- psql -X -U postgres -d postgres -v split=true -f setup.sql # split mode +-- +-- Owner mode (default): the login role `chess` owns schema `chess`, so the +-- server applies migrations itself at startup. It has no other privileges. +-- +-- Split mode: NOLOGIN role `chess_owner` owns the schema and tables; the login +-- role `chess` receives only SELECT/INSERT/UPDATE/DELETE. The server starts +-- only when the schema is current; an administrator migrates, as the OS user +-- postgres, with +-- chess-server db init -dsn "dbname=chess user=postgres \ +-- options='-c role=chess_owner -c search_path=chess'" +-- +-- Neither mode sets a password: the chess OS user authenticates over the Unix +-- socket with peer authentication (see doc/deployment.md for pg_hba.conf). +\set ON_ERROR_STOP on +\if :{?split} +\else +\set split false +\endif + +CREATE ROLE chess LOGIN + NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS + CONNECTION LIMIT 20; + +-- The builtin C.UTF-8 provider gives code-point ordering and Unicode case +-- mapping that do not change with operating-system library upgrades. +CREATE DATABASE chess + OWNER postgres + TEMPLATE template0 + ENCODING 'UTF8' + LOCALE_PROVIDER builtin + BUILTIN_LOCALE 'C.UTF-8'; + +REVOKE ALL ON DATABASE chess FROM PUBLIC; +GRANT CONNECT ON DATABASE chess TO chess; + +\connect chess + +-- Nothing may be created in or read from public; chess lives in its own schema. +REVOKE ALL ON SCHEMA public FROM PUBLIC; + +\if :split +CREATE ROLE chess_owner NOLOGIN + NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; +CREATE SCHEMA chess AUTHORIZATION chess_owner; +GRANT USAGE ON SCHEMA chess TO chess; +-- Tables created by future migrations inherit runtime DML grants. +ALTER DEFAULT PRIVILEGES FOR ROLE chess_owner IN SCHEMA chess + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO chess; +\else +CREATE SCHEMA chess AUTHORIZATION chess; +\endif + +-- Resolve unqualified names in the chess schema without a DSN parameter. +ALTER ROLE chess IN DATABASE chess SET search_path = chess; +-- Bound runaway statements, lock waits, and abandoned transactions. The +-- server's own per-operation deadlines are shorter. +ALTER ROLE chess IN DATABASE chess SET statement_timeout = '30s'; +ALTER ROLE chess IN DATABASE chess SET lock_timeout = '10s'; +ALTER ROLE chess IN DATABASE chess SET idle_in_transaction_session_timeout = '60s'; diff --git a/doc/api.md b/doc/api.md index 70bd35f..7d22f80 100644 --- a/doc/api.md +++ b/doc/api.md @@ -26,6 +26,10 @@ Creates new user account and returns JWT token. - `email` (string, optional): Valid email address - `password` (string, required): Minimum 8 characters, must contain letter and number +Accounts created here are identical to CLI-created accounts and do not +expire. Returns 409 when the username or email is taken, and 503 when the +registration limit (`-max-users`) or password-hashing capacity is exhausted. + **Response (201):** ```json { @@ -101,7 +105,7 @@ Returns server and storage status. ``` Storage states: -- `"disabled"` - No storage path configured +- `"disabled"` - No database configured (`-dsn` or `CHESS_DSN`) - `"ok"` - Database operational with auth enabled - `"degraded"` - A persistence write failed or the write queue filled; live games continue in memory, but durable history is no longer complete @@ -177,7 +181,9 @@ Response includes all game data. Compare `moves` array length to detect changes. `GET /games/{gameId}/history` Returns the persisted replay line even after the live game has been unloaded -from memory or the server has restarted. History is public to anyone who knows +from memory or the server has restarted. Games without a registered player +are deleted 24 hours after their last activity; their history then returns +404. History is public to anyone who knows the game ID, matching the existing public live-game read model. Persistent storage must be enabled. @@ -193,7 +199,7 @@ client can replay the game without running a chess engine. "startTimeUtc": "2026-09-07T12:00:00Z", "endTimeUtc": "2026-09-07T12:15:00Z", "players": { - "white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id"}, + "white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"}, "black": {"id": "player-id", "color": 2, "type": 1} }, "moves": [ @@ -209,10 +215,12 @@ client can replay the game without running a chess engine. ``` `result` is omitted while a game is ongoing. Persisted terminal values are -`white_wins`, `black_wins`, `draw`, and `stalemate`. +`white_wins`, `black_wins`, `draw`, and `stalemate`. A claimed player's `name` +is their username when the claim was recorded; it survives later renames and +account deletion, and is omitted for anonymous and computer players. -Returns 404 when the game has no durable record and 503 when persistence is -disabled. +Returns 400 for a non-canonical game ID, 404 when the game has no durable +record, and 503 when persistence is disabled or degraded. ### List My Stored Games `GET /users/me/games?limit=50&offset=0` @@ -224,8 +232,11 @@ persistent storage. - `limit`: 1-100; defaults to 50 - `offset`: 0-1,000,000; defaults to 0 -Each item contains game ID, initial FEN, result/timestamps, players, and move -count. `nextOffset` is present only when another page exists. +Each item contains game ID, initial FEN, final FEN (after the last stored move, +or the initial FEN when there is none), result/timestamps, players, and move +count. `nextOffset` is present only when another page exists. A game belongs to +a user who created it while authenticated or claimed a slot with a first move; +the association survives later player reconfiguration. **Response (200):** ```json @@ -234,12 +245,13 @@ count. `nextOffset` is present only when another page exists. { "gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", "initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + "finalFen": "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4", "result": "white_wins", "startTimeUtc": "2026-09-07T12:00:00Z", "endTimeUtc": "2026-09-07T12:15:00Z", - "moveCount": 41, + "moveCount": 7, "players": { - "white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id"}, + "white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"}, "black": {"id": "player-id", "color": 2, "type": 1} } } @@ -314,7 +326,12 @@ Error codes: - Registration: 5 requests/minute/IP - Login: 10 requests/minute/IP -Exceeding limit returns 429 status. +Exceeding limit returns 429 status. Behind a reverse proxy the client IP is the +proxy's `X-Real-IP` header, trusted only from addresses listed in +`-trusted-proxies`. + +Password hashing is bounded to four concurrent operations. When all are busy +for five seconds, registration and login return 503 with `RESOURCE_LIMIT`. ## JWT Token Format @@ -323,6 +340,9 @@ Tokens are HS256-signed JWTs valid for 7 days. Include in Authorization header: Authorization: Bearer ``` -Token claims include `sub` (user ID), `username`, `email`, `session_id`, and -`exp` (expiration). Authentication requires the session to exist, be unexpired, -and belong to the JWT subject. +The scheme is case-insensitive and the token must use RFC 6750 syntax; a +malformed header returns 401 even on endpoints where authentication is +optional. Claims are `iss` (`chess-server`), `aud` (`chess-api`), `sub` (user +ID), `iat`, `nbf`, `exp`, and `extra.session_id`. Tokens carry no profile data; +use `/auth/me`. Authentication requires the session to exist, be unexpired, and +belong to the JWT subject. Logging in again replaces the previous session. diff --git a/doc/architecture.md b/doc/architecture.md index f9646cb..1ecd429 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -12,33 +12,47 @@ Central command handler containing business logic. Single `Execute(Command)` ent In-memory live-game storage with authentication support. A mutex protects each game transition and callers receive immutable game views rather than mutable game pointers. The service manages game lifecycle, snapshots, player -configuration, user accounts, JWT tokens, persistence, and terminal-game -eviction. Eviction removes only the memory copy; durable replay data remains. +configuration, user accounts, JWT tokens, persistence, and cleanup. Finished +games leave memory after an hour while their durable rows remain; games with +no registered player leave memory and the database 24 hours after their last +activity (see Retention below). #### Long-Polling Registry (`internal/service/waiter.go`) Manages clients waiting for game state changes via HTTP long-polling. Tracks move counts per client, sends notifications on state changes, enforces 30-second timeout. Non-blocking notification pattern handles slow clients gracefully. Coordinates with service layer for game updates and deletion events. #### Authentication Module (`internal/service/user.go`, `internal/http/auth.go`) -- **Password Hashing**: Argon2id for secure password storage -- **JWT Management**: HS256 tokens with 7-day expiration +- **Password Hashing**: Argon2id (`lixenwraith/auth`), at most four concurrent + derivations; unknown accounts verify against a dummy hash for equal timing +- **JWT Management**: one scoped `auth.JWT` manager (HS256, issuer + `chess-server`, audience `chess-api`, 7-day lifetime, zero leeway); the key + comes from `-jwt-secret-file`, a fixed dev key, or a per-process random key +- **Bearer Parsing**: `auth.ParseBearerToken` (RFC 6750 syntax) - **User Operations**: Registration, login, profile management - **Session Tracking**: One persisted session per user, with JWT subject/session binding and last-login timestamps ### Storage Layer (`internal/server/storage`) -SQLite persistence with ordered asynchronous writes for gameplay and -synchronous writes for authentication. A bounded channel (1,000 operations) -feeds one transactional writer. Move persistence groups the move, first-move -slot claim, and terminal result in one transaction. Replay reads insert a -barrier behind accepted writes and then read the game and moves in one SQLite +PostgreSQL 18 persistence through pgx v5 (`database/sql` via `pgx/stdlib`), +with ordered asynchronous writes for gameplay and synchronous writes for +accounts. A bounded channel (1,000 operations) feeds one transactional writer, +which preserves per-game ordering (a move never overtakes its game's insert or +a rewind). Move persistence groups the move, first-move slot claim, and +terminal result in one transaction. Replay reads insert a barrier behind +accepted writes and then read the game and moves in one REPEATABLE READ snapshot. -WAL, foreign-key enforcement, a five-second busy timeout, and NORMAL -synchronous mode are configured in the connection string so every pooled -connection receives the same settings. The pool is deliberately small (eight -open, four idle) because SQLite has one writer. A write failure or full queue -marks storage degraded; live play remains available in memory and `/health` -reports that durable history may be incomplete. +The writer retries a transaction that failed before COMMIT with a transient +error (lost connection, administrator shutdown, serialization failure, +deadlock) with doubling backoff for about three seconds, so a PostgreSQL +restart does not degrade storage. A COMMIT error (unknown outcome), a permanent +error, or a full queue marks storage degraded; live play remains available in +memory and `/health` reports that durable history may be incomplete. + +Synchronous operations carry a five-second deadline. The pool holds at most ten +connections (five idle). The schema is resolved through `search_path`, so the +role default (`chess`) or a DSN parameter selects it. `InitDB` applies ordered, +transactional migrations under an advisory lock and issues no DDL when the +schema is current, which lets a DML-only runtime role start the server. ### Supporting Modules - **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication @@ -53,9 +67,9 @@ reports that durable history may be incomplete. ### User Registration 1. HTTP handler receives `POST /auth/register` with credentials 2. Validates username format and password strength -3. Service layer hashes password with Argon2id -4. Creates user record with unique ID (collision detection) -5. Generates JWT token +3. Service layer hashes password with Argon2id (bounded concurrency) +4. Creates the user and its initial session in one transaction (random UUIDv4 ID) +5. Generates a scoped JWT bound to that session 6. Returns token and user information ### Authenticated Game Creation @@ -74,7 +88,7 @@ reports that durable history may be incomplete. 5. If legal, gets new FEN from engine 6. Calls `service.ApplyMoveWithState()` with the FEN, turn, and state that were validated 7. Service rejects a stale concurrent commit or atomically updates the move, optional slot claim, and terminal result -8. The same logical mutation is queued as one SQLite transaction +8. The same logical mutation is queued as one database transaction 9. Returns GameResponse ### Computer Move @@ -100,19 +114,22 @@ reports that durable history may be incomplete. 1. Client requests `GET /api/v1/games/{id}/history` 2. Storage queues a barrier after all previously accepted gameplay writes 3. The writer reaches the barrier only after those transactions finish -4. Storage reads the game row and ordered moves in one read transaction +4. Storage reads the game row and ordered moves in one REPEATABLE READ transaction 5. The API returns the initial FEN plus every UCI move and resulting FEN 6. This path works after terminal-memory eviction or a server restart ## Persistence Flow ### User Write Operations (Synchronous) -1. Service serializes public registrations within the process -2. Storage checks uniqueness and capacity in a transaction -3. At capacity, the oldest temporary user is evicted in that transaction -4. The new user and initial session commit together; any failure rolls back the entire operation -5. Login replaces the user's single session with one SQLite UPSERT -6. Other account mutations commit before success is returned +1. The password is hashed outside any lock, within the KDF concurrency bound +2. Storage takes a transaction-scoped advisory lock, so account creation is + serialized across the server and CLI processes +3. Uniqueness is checked first, so a duplicate is reported even when registration is full +4. Public registration is refused once `-max-users` accounts exist; nothing is evicted +5. The new user and initial session commit together; any failure rolls back the entire operation +6. Login replaces the user's single session and records `last_login_at` in one statement +7. Other account mutations commit before success is returned; unique violations map to "already exists" +8. Registered and CLI-created accounts are identical and never expire ### Game Write Operations (Asynchronous) 1. Service layer calls a storage method (`RecordNewGame`, `RecordMove`, `RecordPlayers`, or `RewindGame`) @@ -124,10 +141,14 @@ reports that durable history may be incomplete. ### Query Operations 1. Replay-sensitive game reads wait on the async-write barrier -2. User-game filtering matches creation-time player IDs and later claim IDs -3. Move lookup uses the `UNIQUE(game_id, move_number)` index prefix and returns move order directly -4. Username and email lookups are case-insensitive -5. CLI database queries format records as tabular output +2. "My games" matches claims (`white_claimed_by`/`black_claimed_by`) through two + composite partial indexes; authenticated creators and first movers always + record a claim, and claims survive player reconfiguration +3. The listing reads each game's move count and final FEN from its last move + with a LATERAL probe of the `moves` primary key, not a per-row count +4. Move history is read in primary-key order `(game_id, move_number)` +5. Usernames and emails are stored lowercase; lookups lower their input +6. Malformed IDs are treated as "not found" before reaching the database ## Concurrency @@ -137,7 +158,8 @@ reports that durable history may be incomplete. - **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes - **Validation Engine**: Single mutex-protected instance for synchronous validation - **Storage Writer**: Single goroutine processes game write queue sequentially -- **User Operations**: Direct database access with transaction isolation +- **User Operations**: Direct database access; account creation serialized by a PostgreSQL advisory lock +- **Password Hashing**: Four Argon2id slots; waiters give up after five seconds (HTTP 503) - **PID Lock**: File-based exclusive lock prevents multiple instances ## Data Structures @@ -165,15 +187,18 @@ type Snapshot struct { ``` ### JWT Claims -```go +```json { + "iss": "chess-server", + "aud": ["chess-api"], "sub": "user-id", - "username": "alice", - "email": "alice@example.com", - "session_id": "session-id", - "exp": 1234567890 + "iat": 1234567890, + "nbf": 1234567890, + "exp": 1235172690, + "extra": {"session_id": "session-id"} } ``` +Profile data is not placed in the token; clients read it from `/auth/me`. ### Command Pattern with User Context Commands encapsulate operations with type, arguments, and optional user ID for authenticated requests. @@ -181,67 +206,79 @@ Commands encapsulate operations with type, arguments, and optional user ID for a ### Player Configuration Players identified by UUID (authenticated users) or generated IDs (anonymous), configured with type (human/computer), skill level, and search time. -### Storage Schema +### Storage Schema (version 1) ```sql --- User authentication table users ( - user_id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL COLLATE NOCASE, - email TEXT COLLATE NOCASE, - password_hash TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_login_at DATETIME + user_id uuid PRIMARY KEY, + username text NOT NULL UNIQUE, -- lowercase, 1-64 characters + email text UNIQUE, -- lowercase or NULL + password_hash text NOT NULL, -- Argon2id PHC string + created_at timestamptz NOT NULL, + last_login_at timestamptz +) + +sessions ( + session_id uuid PRIMARY KEY, + user_id uuid NOT NULL UNIQUE REFERENCES users ON DELETE CASCADE, + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL ) --- Game storage with player associations games ( - game_id TEXT PRIMARY KEY, - initial_fen TEXT, - white_player_id TEXT, -- User ID or generated UUID - white_type INTEGER, - white_level INTEGER, - white_search_time INTEGER, - black_player_id TEXT, -- User ID or generated UUID - black_type INTEGER, - black_level INTEGER, - black_search_time INTEGER, - start_time_utc DATETIME, - result TEXT, -- white_wins, black_wins, draw, or stalemate - end_time_utc DATETIME, - white_claimed_by TEXT, -- user that claimed the slot after creation - black_claimed_by TEXT + game_id uuid PRIMARY KEY, + initial_fen text NOT NULL, + white_player_id uuid NOT NULL, -- user ID or generated UUID + white_type smallint NOT NULL, -- 1 human, 2 computer + white_level smallint NOT NULL, -- 0-20 + white_search_time integer NOT NULL, + white_claimed_by uuid, -- user that owns the slot + white_name text, -- claimant's username at claim time + black_... (same columns), + start_time_utc timestamptz NOT NULL, + result text, -- white_wins, black_wins, draw, stalemate + end_time_utc timestamptz, -- set exactly when result is set ) --- Move history moves ( - move_id INTEGER PRIMARY KEY, - game_id TEXT, - move_number INTEGER, - move_uci TEXT, - fen_after_move TEXT, - player_color TEXT, - move_time_utc DATETIME, - FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE, - UNIQUE (game_id, move_number) + game_id uuid REFERENCES games ON DELETE CASCADE, + move_number integer, -- 1..n without gaps + move_uci text NOT NULL, -- [a-h][1-8][a-h][1-8][qrbn]? + fen_after_move text NOT NULL, + player_color text NOT NULL, -- 'w' | 'b' + move_time_utc timestamptz NOT NULL, + PRIMARY KEY (game_id, move_number) ) ``` -Schema version 2 is applied idempotently to legacy databases with guarded -`ALTER TABLE ADD COLUMN` migrations. Redundant indexes formerly duplicating -UNIQUE constraints or useful index prefixes are removed. Purpose-built partial -indexes cover non-empty email uniqueness, temporary-user cleanup, session -expiry, and post-creation game claims. +`schema_version` records the applied version. Indexes cover session expiry, +each claim column ordered by `(start_time_utc DESC, game_id DESC)` for the game +listing, and the start time of unclaimed games for the anonymous-game purge. +Claims and player IDs deliberately have no foreign key to `users`: history +outlives deleted accounts, and the name snapshot keeps it readable. + +### Retention +- Sessions are deleted after expiry (7 days after the last login). +- Finished games leave memory after `-finished-game-ttl` (1 hour); their rows stay. +- Games with no claimed slot are unloaded from memory and deleted from the + database 24 hours after their last activity (`-anonymous-game-ttl`). The + hourly cleanup evicts idle games first, then queues the delete through the + ordered writer while excluding every game still loaded, so no live game can + lose its row. See [`deploy/postgresql`](../deploy/postgresql) for +provisioning and the one-time SQLite import. ## Security Architecture ### Authentication Flow 1. Password validation enforces minimum complexity 2. Argon2id hashing prevents rainbow table attacks -3. JWT tokens expire after 7 days -4. Case-insensitive username/email matching prevents enumeration -5. Constant-time password verification prevents timing attacks +3. JWT tokens expire after 7 days and must name this service as issuer/audience +4. Case-insensitive username/email matching prevents duplicate accounts +5. Unknown accounts and wrong passwords cost the same Argon2id work and + return the same error ### Rate Limiting Strategy +- Client IP: the `-proxy-header` value (default `X-Real-IP`) only on connections + from `-trusted-proxies`; otherwise the TCP peer - General API: 10 req/s per IP (20 in dev mode) - Registration: 5 req/min per IP (prevent spam accounts) - Login: 10 req/min per IP (prevent brute force) @@ -249,7 +286,8 @@ expiry, and post-creation game claims. ### Data Protection - Passwords never stored in plaintext -- JWT secret rotates on restart (or fixed in dev mode) +- JWT key from a 0600 key file, or rotated on restart (fixed in dev mode) +- Database access by Unix-socket peer authentication; no stored DB password - User IDs use UUIDs with collision detection - Transactions keep registration, sessions, moves, claims, results, and rewinds internally consistent - Case-insensitive queries prevent duplicate accounts diff --git a/doc/deployment.md b/doc/deployment.md new file mode 100644 index 0000000..e42b42d --- /dev/null +++ b/doc/deployment.md @@ -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 `). + +## 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 '` + (`--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 +``` diff --git a/doc/development.md b/doc/development.md index 37a6cd2..343a56a 100644 --- a/doc/development.md +++ b/doc/development.md @@ -4,7 +4,7 @@ - Go 1.26+ - Stockfish in PATH -- SQLite3 +- PostgreSQL 18 (server for persistence; `psql` for the shell test suites) - Git - curl, jq (for testing) @@ -26,12 +26,17 @@ go build ./cmd/chess-client-cli - `-web-host`: Web UI server host (default: localhost) - `-web-port`: Web UI server port (default: 9090) - `-dev`: Development mode with relaxed rate limits and fixed JWT secret -- `-storage-path`: SQLite database file path (enables persistence and authentication) +- `-dsn`: PostgreSQL connection string (default `$CHESS_DSN`); enables persistence and authentication +- `-jwt-secret-file`: Stable JWT signing key file, mode 0600, at least 32 bytes (default `$CHESS_JWT_SECRET_FILE`) +- `-trusted-proxies`: Comma-separated reverse-proxy IPs/CIDRs whose `-proxy-header` is trusted +- `-proxy-header`: Client-IP header from a trusted proxy (default `X-Real-IP`) - `-pid`: PID file path for process tracking - `-pid-lock`: Enable exclusive locking (requires -pid) - `-log-level`: `debug`, `info`, `warn`, or `error` (default: `info`) - `-log-http`: Enable API and web request logs (default: `true`) - `-finished-game-ttl`: How long terminal games stay in memory (default: `1h`; `0` disables eviction) +- `-anonymous-game-ttl`: How long games without a registered player survive after their last activity, in memory and in the database (default: `24h`; `0` keeps them) +- `-max-users`: Accounts at which public registration closes (default: `100`; `0` = no limit; the CLI is not limited) - `-web-api-url`: Browser-visible API origin for the embedded web client; useful when its public origin differs from the listen address ### Modes @@ -40,84 +45,107 @@ go build ./cmd/chess-client-cli ./chess-server # With persistence and authentication -./chess-server -storage-path ./db/chess.db +./chess-server -dsn 'dbname=chess' # Development with all features -./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -serve +./chess-server -dev -dsn 'dbname=chess' -pid /tmp/chess-server.pid -serve # Detailed persistence, engine-queue, cleanup, and request logs -./chess-server -dev -storage-path chess.db -serve -log-level debug -log-http=true +./chess-server -dev -dsn 'dbname=chess' -serve -log-level debug -log-http=true + +# Behind a reverse proxy that sets X-Real-IP +./chess-server -dsn 'dbname=chess' -trusted-proxies 10.0.0.1 # Web UI is public at one origin while the API is exposed at another ./chess-server -serve -web-api-url https://api.example.test -# Initialize database with user tables -./chess-server db init -path chess.db +# Create or migrate the schema +./chess-server db init -dsn 'dbname=chess' ``` +### Local PostgreSQL + +Any PostgreSQL 18 works for development. With a local server and peer +authentication for your OS user: + +```bash +sudo -u postgres createuser "$USER" +sudo -u postgres createdb -O "$USER" chess +export CHESS_DSN='dbname=chess' +``` + +Production provisioning (roles, schema, `pg_hba.conf`) is in +[deployment.md](./deployment.md). + ## Database Management +All `db` subcommands take `-dsn`, defaulting to `$CHESS_DSN`; the examples +below assume it is exported. Commands other than `init` and `delete` refuse to +run against a missing or outdated schema. + ### Schema Initialization ```bash -# Create all tables (users, games, moves) -./chess-server db init -path chess.db +# Create or migrate all tables; safe to repeat +./chess-server db init ``` ### User Management CLI ```bash # Add user with password -./chess-server db user add -path chess.db -username alice -password SecurePass123 +./chess-server db user add -username alice -password SecurePass123 # Add user with email -./chess-server db user add -path chess.db -username bob -email bob@example.com -password BobPass456 +./chess-server db user add -username bob -email bob@example.com -password BobPass456 # Interactive password input -./chess-server db user add -path chess.db -username charlie -interactive +./chess-server db user add -username charlie -interactive # List all users -./chess-server db user list -path chess.db +./chess-server db user list # Update password -./chess-server db user set-password -path chess.db -username alice -password NewPass789 +./chess-server db user set-password -username alice -password NewPass789 # Update email -./chess-server db user set-email -path chess.db -username alice -email newemail@example.com +./chess-server db user set-email -username alice -email newemail@example.com # Update username -./chess-server db user set-username -path chess.db -current alice -new alice2 +./chess-server db user set-username -current alice -new alice2 # Import with existing Argon2 hash -./chess-server db user set-hash -path chess.db -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...' +./chess-server db user set-hash -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...' # Delete user -./chess-server db user delete -path chess.db -username alice +./chess-server db user delete -username alice ``` ### Game Query CLI ```bash # Query all games -./chess-server db query -path chess.db -gameId "*" +./chess-server db query -gameId "*" # Query games for specific user -./chess-server db query -path chess.db -playerId "550e8400-e29b-41d4-a716-446655440000" +./chess-server db query -playerId "550e8400-e29b-41d4-a716-446655440000" # Query specific game -./chess-server db query -path chess.db -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef" +./chess-server db query -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef" -# Delete database (destructive) -./chess-server db delete -path chess.db +# Drop every chess table in the DSN's search_path (destructive) +./chess-server db delete -confirm ``` ## Authentication Configuration ### JWT Secret Management -- **Production**: A cryptographically secure 32-byte secret is generated on - startup. This intentionally invalidates JWTs after a restart even though the - SQLite session rows remain; configuring a stable deployment secret is tracked - in `doc/todo.md`. -- **Development** (`-dev`): Fixed secret for testing consistency -- **Sessions**: Stored for 7 days and renewed on each login; effective token - lifetime is also bounded by signing-key rotation +- **Production**: Provide `-jwt-secret-file` so tokens survive restarts. The + file must be a regular file with no group/other permissions and hold at + least 32 bytes (`openssl rand -base64 48`). Without it, a random key is + generated per process and every token is invalidated on restart, although + the session rows remain. +- **Development** (`-dev`): Fixed secret for testing consistency when no key file is given +- **Scope**: Tokens are issued for `chess-server` / `chess-api`, carry only the + subject and session ID, and are accepted only while their session row exists +- **Sessions**: Stored for 7 days and renewed on each login ### Password Requirements - Minimum 8 characters @@ -160,7 +188,7 @@ chess/ │ │ ├── service.go # Core service │ │ ├── game.go # Game operations │ │ └── user.go # User and auth operations -│ └── storage/ # SQLite persistence +│ └── storage/ # PostgreSQL persistence (pgtest: per-test schemas) │ ├── storage.go # Async writer for games │ ├── game.go # Game persistence │ ├── user.go # User persistence (synchronous) @@ -180,13 +208,15 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri # User authentication and database tests ./test/test-db.sh -# Run test server with sample users -./test/test-db-server.sh +# Run test server with sample users (needs a disposable CHESS_TEST_DSN) +./test/run-test-server.sh # Test real-time game updates via long-polling ./test/test-longpoll.sh -# Unit, migration, and persistence tests +# Unit, migration, and persistence tests; database tests are skipped unless +# CHESS_TEST_DSN names a database the test role may create schemas in +export CHESS_TEST_DSN='postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable' go test ./... # Concurrency checks for the state/persistence boundary @@ -201,8 +231,11 @@ go test -race ./internal/server/storage ./internal/server/service - Queue capacity: 100 (internal/processor/queue.go) - Min search time: 100ms (internal/processor/processor.go) - Write queue: 1000 operations (internal/server/storage/storage.go) -- DB connections: 8 max, 4 idle (internal/server/storage/storage.go) +- DB connections: 10 max, 5 idle (internal/server/storage/storage.go) +- DB operation deadline: 5 seconds; write transactions 10 seconds +- Transient write retries: 6 attempts, 100 ms doubling backoff - JWT expiration: 7 days (internal/service/user.go) +- Concurrent Argon2id derivations: 4, five-second wait (internal/service/user.go) - Long-poll timeout: 30 seconds (internal/server/service/waiter.go) - Long-poll channel buffer: 1 (internal/service/waiter.go) @@ -213,20 +246,21 @@ go test -race ./internal/server/storage ./internal/server/service - Hash algorithm: Argon2id (memory-hard, side-channel resistant) ### Storage Configuration -- WAL mode and NORMAL synchronous mode enabled on every connection -- Foreign key constraints and a five-second busy timeout enabled on every connection +- PostgreSQL via pgx; schema chosen by `search_path`; migrations run at startup - Async write pattern for games; shutdown drains every accepted write -- Replay reads wait for prior queued writes and use one read transaction +- Transient failures before COMMIT are retried; others degrade storage +- Replay reads wait for prior queued writes and use one REPEATABLE READ transaction - Synchronous writes for user operations (data consistency) -- Registration capacity/eviction, user creation, and initial session are atomic -- A full queue or write failure degrades to memory-only and is visible in logs and `/health` -- Case-insensitive collation for usernames and emails +- Registration limit check, user creation, and initial session are atomic +- Games without a registered player are deleted 24 hours after their last activity +- A full queue or unrecovered write failure degrades to memory-only and is visible in logs and `/health` +- Usernames and emails stored lowercase with unique constraints ### Rate Limiting Configuration - General endpoints: 10 req/s (20 in dev mode) - User registration: 5 req/min - User login: 10 req/min -- Rate limit key: IP address from X-Forwarded-For or connection +- Rate limit key: `-proxy-header` (default `X-Real-IP`) from `-trusted-proxies`, otherwise the TCP peer ### PID Management - Singleton enforcement requires same PID file path @@ -247,10 +281,11 @@ go test -race ./internal/server/storage ./internal/server/service ### Authentication Security - Passwords hashed with Argon2id before storage -- JWT tokens signed with HS256 -- Constant-time password comparison -- Case-insensitive matching prevents user enumeration +- JWT tokens signed with HS256 and scoped by issuer/audience +- Constant-time password comparison; unknown accounts verify a dummy hash +- Case-insensitive matching prevents duplicate accounts - Rate limiting on auth endpoints prevents brute force +- Argon2id concurrency is bounded to cap memory under load ### Input Validation - All user inputs validated and sanitized @@ -261,18 +296,19 @@ go test -race ./internal/server/storage ./internal/server/service ### Session Management - JWT tokens expire after 7 days - No token refresh mechanism (re-login required) -- Tokens include minimal claims (user ID, username, email) -- Secret rotates on server restart (except dev mode) +- Tokens carry only the user ID and session ID +- Key is stable with `-jwt-secret-file`; otherwise it rotates on restart (fixed in dev mode) ## Limitations - JWT tokens don't support refresh (must re-login after expiry) -- User deletion doesn't cascade to games (games remain with player IDs) +- User deletion doesn't cascade to games (games keep player and claim IDs) +- Games played without a registered player are deleted 24 hours after their last activity - No password recovery mechanism - No email verification for registration - Fixed worker pool size for engine calculations - No push-based game updates (30-second long-polling is used) - Live games are not rehydrated after restart; persisted games are currently replay-only - Database history has no automatic retention policy -- Curated-game metadata and replay controls are deferred to [Replay Implementation Tasks](./todo.md) +- Replay UI, PGN export, and curated games are planned in [Replay Plan](./todo.md) - REST API only diff --git a/doc/todo.md b/doc/todo.md index e329fb2..f09d619 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -1,266 +1,159 @@ -# Replay Implementation Tasks +# Game Replay Plan -This plan covers the remaining work needed for first-class replay in the web -and CLI clients, durable player-game browsing, and a curated archive of famous -games. The persistence/API foundation completed by the database audit is listed -first so later work does not duplicate or bypass it. +Target experience: a user registers, signs in, plays, and later opens **My +games** to pick any finished or unfinished game. Replay runs automatically or +one ply at a time for either side, moves backward and forward freely, and +exports the FEN or PGN of the position at the current ply, in the spirit of the +chess.com analysis viewer. -## Foundation Available Now +This plan starts from the PostgreSQL release and splits the remaining work +into phases that each ship and test on their own. -- [x] Store terminal `result` and `end_time_utc` on each game. -- [x] Persist `white_claimed_by` and `black_claimed_by`, including claims made - on the first valid move after game creation. -- [x] Commit each move, first-move claim, and move-caused terminal result in one - SQLite transaction. -- [x] Rewind moves and clear a stale terminal result/end time in one transaction. -- [x] Return ordered UCI moves with `fenAfterMove` through - `GET /api/v1/games/{gameId}/history`. -- [x] Return bounded pages through authenticated - `GET /api/v1/users/me/games?limit=&offset=`. -- [x] Provide matching Go client DTOs and methods (`GetGameHistory`, - `GetMyGames`) without prematurely adding CLI presentation. -- [x] Evict terminal games from memory after a configurable TTL while retaining - durable rows and moves. -- [x] Add an async-write barrier and a single SQLite read snapshot for immediate, - internally consistent replay reads. -- [x] Configure browser API origin through `/config`, with `/chess` fallback for - the static deployment at `lixen.com/projects/chess/`. -- [x] Add debug-level persistence, cleanup, engine queue, and lifecycle logging. +## Foundation in Place -## Decisions Required Before Replay UI Work +- [x] PostgreSQL 18 schema with durable games, ordered moves, results, end + times, and slot claims; transactional migrations (`schema_version`). +- [x] Each accepted move, first-move claim, and move-caused result commits in + one transaction; undo removes moves and clears the result atomically. +- [x] `GET /api/v1/games/{id}/history`: initial FEN plus UCI and resulting FEN + for every ply, read in one REPEATABLE READ snapshot after pending writes. +- [x] `GET /api/v1/users/me/games`: claimed games, newest first, with move + count and final FEN (thumbnail) from a primary-key probe per game. +- [x] Claims survive player reconfiguration and terminal-game eviction; the + listing indexes are ordered for keyset pagination. +- [x] Stable JWT key (`-jwt-secret-file`), so a returning user's session + survives server restarts. +- [x] Uniform, non-expiring accounts for site and CLI users (D1); 24-hour + retention of games without a registered player (D1). +- [x] Player-name snapshots on claim, returned in history and listings (D4). +- [x] Scripted FreeBSD jail deployment (`deploy/`). +- [x] Go client DTOs (`GetGameHistory`, `GetMyGames`) including `finalFen`. -| Decision | Current behavior | Decision needed | +## Decisions + +| # | Decision | Status | |---|---|---| -| History visibility | Public to anyone with a game UUID, like live game reads | Keep public, make games private by default, or add per-game visibility | -| Live mutation authorization | Configure, undo, computer-trigger, and unload remain UUID-based; claimed slots protect human moves only | Define owner/opponent/spectator permissions before replay and archive UI expose more game discovery | -| Database retention | Indefinite; only terminal in-memory state is evicted | Retention by account type, archive status, age, or explicit deletion | -| Delete semantics | `DELETE /games/{id}` unloads memory but retains history | Name it “close/unload,” or add a separate authorized durable delete | -| Durability guarantee | Gameplay continues after a write failure; health becomes degraded | Keep best-effort, acknowledge writes, retry with an outbox, or fail gameplay closed | -| Result model | `white_wins`, `black_wins`, `draw`, `stalemate` | Keep compatibility or split outcome (`1-0`, `0-1`, `1/2-1/2`) from termination reason | -| Archived-game owner | Not created | Protected system/demo user, separate archive owner table, or ownerless source records | -| Replay notation | UCI plus authoritative FEN after each move | Add SAN and canonical PGN at import/write time or derive them on read | +| D1 | Account lifetime | **Done.** Site registrations and CLI-created accounts are identical and never expire; public registration closes at `-max-users`. Games without a registered player are deleted 24 hours after their last activity (`-anonymous-game-ttl`). | +| D2 | History visibility | **Decided: shareable by game ID.** The history endpoint stays public to holders of the 122-bit random ID; IDs are never listed publicly and the list endpoint stays authenticated. | +| D3 | Where SAN/PGN is produced | **Decided: server-side Go** (R1), derived on read from the initial FEN and the UCI line: no schema change, one implementation for web and CLI, no third-party script under the CSP. | +| D4 | Player names | **Done.** `white_name`/`black_name` snapshot the claimant's username in the claim transaction and survive renames and deletions; exposed as `players.*.name`. | +| D5 | Draw rules | Open. Recommendation: detect insufficient material, threefold repetition, and the 50/75-move rules in the R1 core; add resign/draw offers later. PGN `Result` is `*` for unterminated games. | +| D6 | Undo after a result | **Decided: keep allowed**, so a player can step back from a finished game and play a line again. Consequence: a finished game's stored history, result, and end time are rewritten by undo; replay shows the line as it stands now. | +| D7 | List pagination | Open. Recommendation: keyset cursor on `(start_time_utc, game_id)`, keeping offset during v1; the claim indexes are already ordered for it. | -Record these choices in an ADR before changing the v1 response contract. Until -privacy is decided, do not add searchable public player-game indexes or expose -usernames in public history. +## Phase R1 — Notation Core (server, no API change) -## Phase 1 — Complete the Durable Game Model +A small, dependency-free chess core in Go (`internal/server/chess`), used by +replay only; Stockfish remains the move validator for live play. -### Results and termination +- [ ] Board model with FEN parse/serialize (extend `internal/server/board`). +- [ ] Legal move generation: pseudo-legal moves plus king-safety filtering, + castling rights and paths, en passant, promotion. +- [ ] SAN encode (disambiguation by file, rank, or both; `+`/`#`; `O-O`, + `O-O-O`; `=Q`) and SAN decode for later PGN import. +- [ ] PGN writer: Seven Tag Roster (White/Black from the name snapshots, else + "Anonymous" or "Stockfish level N"), `SetUp`/`FEN` tags for custom starts, + `Termination`, 80-column movetext, `Result` from the stored outcome. +- [ ] Draw-rule helpers (D5) and an integrity check that replays the stored UCI + line and compares each generated FEN with the stored `fenAfterMove`. -- [ ] Represent outcome separately from termination reason. Candidate fields: - `outcome`, `termination`, and optional `result_detail`. -- [ ] Detect and persist all supported draw paths, not only stalemate: - insufficient material, repetition, fifty/seventy-five-move rule, and agreed - draw if that interaction is added. -- [ ] Define behavior for resignation, timeout, abandonment, engine failure, - and administrative termination. -- [ ] Add constraints covering valid combinations: an end time requires a - terminal outcome; an ongoing game has neither. -- [ ] Decide whether undoing a finished rated/player game is allowed. If yes, - preserve an audit event rather than silently rewriting official history. +Test gate: -### Stable participant metadata +- [ ] Perft node counts for the standard CPW positions (start, Kiwipete, and + positions 3–6) to depth 4, which exercises castling, en passant, promotion, + and pins. +- [ ] Property tests over random legal games: SAN decode(encode(m)) == m; the + generated FEN sequence is identical to one produced by Stockfish's `d` + command for the same line. +- [ ] Fuzz targets for FEN parsing, SAN decoding, and PGN writing (no panics, + bounded output). +- [ ] Integrity check run over every stored game in a production-shaped copy. -- [ ] Snapshot display names at game start/end so replay remains readable after - a user rename or temporary-account deletion. -- [ ] Separate historical participant identity from mutable controller config. - Changing a human slot to a computer must never remove the user's game link. -- [ ] Decide whether anonymous players receive a durable pseudonym, remain - unnamed, or are excluded from archive browsing. -- [ ] Add optional clocks/time-control metadata before timeout results are - supported. +## Phase R2 — Replay API -### Notation and integrity +- [ ] Implement D7 (keyset cursor) once decided. +- [ ] Additive history fields: `san` per move, `outcome`/`termination`, and + `pgnResult`; keep existing fields unchanged. +- [ ] `GET /api/v1/games/{id}/pgn?ply=N`: `application/x-chess-pgn`, + `Content-Disposition: attachment; filename="chess--.pgn"`, + moves 1..N (default all). The FEN at any ply is already in the history. +- [ ] `ETag` and `Cache-Control: private, max-age` for terminal games; + `If-None-Match` returns 304. +- [ ] Filters on the list endpoint: result, color, ongoing/finished. +- [ ] Tests: handler tests against PostgreSQL (pgtest), golden PGN files for + mate, stalemate, promotion, castling, en passant, custom FEN, and an ongoing + game; shell-suite coverage for authorization (another user's list, expired + session) and cursor stability under concurrent inserts. -- [ ] Add SAN per ply and canonical PGN, or add a deterministic backend - converter from the stored initial FEN/UCI line. -- [ ] Validate that `move_number`, `player_color`, FEN side-to-move, and the - previous position form one legal continuous line. -- [ ] Add a stored content hash for import idempotency and corruption checks. -- [ ] Add a repair/audit CLI command that reports broken game rows without - mutating them; make repair an explicit separate operation. -- [ ] Define a schema-migration policy beyond v2, including forward-version - rejection, backup instructions, and rollback limitations. +## Phase R3 — Web Replay UI -## Phase 2 — Replay and Library APIs +Plain JavaScript in the embedded client, no framework. -### Player games +- [ ] **My games** button beside the account indicator (authenticated only) + opening a panel: mini-board from `finalFen`, result, date, opponent or + engine level, move count; loading, empty, error, and "load more" states. +- [ ] Replay mode entered from the panel or a deep link + `?replay=&ply=`; `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 ``). +- [ ] While replaying: stop live long-polling, disable move, undo, new-game, + and player-configuration controls; **Back to live game** restores them. -- [ ] Add filters to the authenticated list: `status`, `result`, color, opponent - type, and date range. -- [ ] Replace offset pagination with a stable `(start_time_utc, game_id)` cursor - before the table grows large; retain v1 offset parameters during migration. -- [ ] Return a compact display label/opponent summary so clients do not recreate - association logic. -- [ ] Define whether an authenticated user may list a game merely created for - their random player ID versus one explicitly claimed by them. -- [ ] Add authorization tests for expired/deleted sessions and attempts to list - another user's games. +Constraints from the host's security headers: -### Replay payload +- `script-src 'self' 'wasm-unsafe-eval'`: all code in `app.js`; no inline + scripts, `on*` attributes, `eval`, or `new Function`. +- `style-src 'self' 'unsafe-inline'`: styles belong in `style.css`; state + classes instead of inline style strings. +- `img-src 'self' data:`: pieces remain Unicode glyphs or `data:` SVG. +- `connect-src 'self'`: all requests go to the same origin under `/chess/` + (the existing `/config` fallback); no CDN or cross-origin API. +- `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN`: the iframe host + page must stay on the same origin. Downloads from `blob:` URLs and + `navigator.clipboard` work in a same-origin frame. -- [ ] Version the history payload before adding annotations, evaluations, - comments, variations, clocks, or PGN tags. -- [ ] Include a canonical final FEN and normalized outcome/termination fields. -- [ ] Decide whether long games return one payload or paged/chunked moves. -- [ ] Add `ETag`/`If-None-Match` for immutable finished histories. -- [ ] Add a downloadable PGN response with correct `Content-Type` and filename. -- [ ] Return an explicit “ongoing/incomplete” marker when history is requested - before a terminal result. +Test gate: -### Live-game restoration +- [ ] Playwright (Chromium) end-to-end against server and PostgreSQL: play a + short game, open My games, step backward and forward, autoplay to the end, + and compare exported FEN/PGN with the API. +- [ ] Keyboard-only navigation and screen-reader labels; reduced-motion + disables autoplay animation. +- [ ] Deep link works beneath `/projects/chess/` with the API at `/chess`. -- [ ] Decide whether a server restart should make unfinished games playable or - replay-only. -- [ ] If play must resume, load the last persisted FEN, next turn, player config, - claims, and move list into memory at startup. -- [ ] Mark games interrupted in `pending` state as recoverable `stuck` or - `ongoing`; never re-submit an engine task blindly. -- [ ] Define reconciliation when the service previously entered degraded mode - and memory contains moves absent from SQLite. +## Phase R4 — CLI and WASM Replay (optional) -## Phase 3 — Curated Famous-Game Archive +- [ ] `games` (list with pagination), `replay `, `next`, `prev`, + `first`, `last`, `goto `, `auto `, `fen`, `pgn save `. +- [ ] Replay state is separate from the live session: no polling, moves, undo, + or configuration while replaying. -### Schema and ownership +## Phase R5 — Curated Archive (later) -- [ ] Add a game origin such as `player`, `curated`, or `imported`. -- [ ] Add searchable archive metadata: title, event, site, event date, round, - white/black display names, Elo values, ECO/opening, source URL, source license, - attribution text, and import timestamp. -- [ ] Add publication state, featured flag, and explicit featured rank/order. -- [ ] Create a protected demo/system identity only if ownership remains tied to - users. It must not consume temporary-user capacity, expire, authenticate, or - be evicted/deleted through normal user tools. -- [ ] Prefer a separate protected archive owner over credentials embedded in - seed scripts. -- [ ] Add only indexes backed by actual archive queries; confirm each with - `EXPLAIN QUERY PLAN` and a representative data volume. +- [ ] Schema: game origin (`player`, `curated`), archive metadata (event, site, + date, round, names, Elo, ECO, source URL, license, attribution), publication + state, featured order; no user ownership. +- [ ] `chess-server db archive import ` using the R1 SAN decoder: + validate every move, import each game in one transaction, idempotent by + content hash, dry-run and per-file summary. +- [ ] Public, bounded, cacheable list endpoint; replay through the same + history/PGN endpoints. +- [ ] Verify redistribution rights for every bundled collection. -### Import pipeline +## Operations Carried Forward -- [ ] Add `chess-server db archive import` for one PGN or a directory. -- [ ] Parse PGN tags, comments, NAGs, and variations deliberately; document - which are preserved and which are discarded in the first version. -- [ ] Validate every main-line move from its initial position and generate the - authoritative FEN sequence before opening the transaction. -- [ ] Import a game and all moves in one transaction. -- [ ] Make repeated imports idempotent by source key/content hash. -- [ ] Add dry-run, structured error output, per-file summary, and all-or-nothing - versus continue-on-error modes. -- [ ] Preserve source attribution and verify redistribution rights for every - bundled collection. -- [ ] Seed a small, reviewed fixture set in tests; keep large archives outside - the executable and repository unless licensing and binary size are accepted. - -### Archive API - -- [ ] Add a public, bounded curated list endpoint with stable sorting. -- [ ] Add exact filters required by the UI (featured, player name, event, year, - ECO); do not expose an unconstrained database query API. -- [ ] Reuse the same history representation for player and curated games. -- [ ] Cache immutable curated list/history responses and invalidate only on - archive administration. - -## Phase 4 — CLI Replay Experience - -- [ ] Add `games`/`games mine` to call `GetMyGames`, show pagination, result, - colors, opponent/controller, date, and move count. -- [ ] Add `games featured` after the curated endpoint exists. -- [ ] Add `replay ` 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 ` 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=`, that works - beneath `/projects/chess/` and does not assume the API shares that path. -- [ ] Initialize from `initialFen`; step by assigning the stored - `fenAfterMove`, not by replaying moves through a browser chess engine. -- [ ] Add previous/next/start/end buttons, move-list selection, keyboard - controls, autoplay speed, pause, and current-ply announcement. -- [ ] Disable move, computer-trigger, undo, and player-configuration actions in - replay mode. -- [ ] Stop live long-polling when replay mode begins and restore it only when a - live game is explicitly reopened. -- [ ] Show result, termination, players, date/event, source attribution, and - custom-start notice. -- [ ] Make browser back/forward restore list filters and replay ply. -- [ ] Test both embedded `/config` and the deployed `/chess` fallback, including - CORS and reverse-proxy headers. -- [ ] Add responsive and accessibility checks for board orientation, focus, - screen-reader labels, reduced motion, and high contrast. - -## Phase 6 — Durability, Operations, and Scale - -- [ ] Choose and implement the durability contract from the decision table. - For acknowledged persistence, return success only after a writer receipt or - use a durable outbox with retries and ordering. -- [ ] Expose counters/metrics for queue depth, enqueue rejection, write latency, - failed transaction, barrier latency, replay read latency, and terminal-memory - eviction. -- [ ] Add request/game correlation fields to logs without logging JWTs, - passwords, or full private payloads. -- [ ] Configure a stable production JWT signing key (prefer a secret file or - deployment secret) so persisted sessions can survive a server restart; - document rotation and invalidation procedures. -- [ ] Add a bounded degraded-mode recovery procedure; current behavior requires - operator intervention/restart and cannot reconstruct missing writes. -- [ ] Benchmark list and history queries with realistic user/archive sizes and - verify query plans in CI. -- [ ] Set WAL checkpoint and database backup procedures; test online backup and - restore with active reads/writes. -- [ ] Define database retention separately for anonymous, temporary-user, - permanent-user, and curated games. -- [ ] Add authorized durable deletion/anonymization if required by the privacy - policy, with archive records protected from accidental cascades. - -## Required Test Matrix - -- [ ] Upgrade a production-shaped legacy database to every new schema version - and reopen it with foreign keys enabled on multiple pooled connections. -- [ ] Read history immediately after create, move, terminal move, slot claim, - player reconfiguration, and undo—without sleeps. -- [ ] Run concurrent legal moves from one position; exactly one may commit and - the loser must receive `GAME_CONFLICT`. -- [ ] Submit duplicate computer triggers; only one engine task may run. -- [ ] Fill the write queue/fault SQLite and assert degraded health, visible - logging, and documented client behavior. -- [ ] Shut down with queued writes and prove all accepted writes drain. -- [ ] Restart after a finished game and replay the exact FEN sequence/result. -- [ ] Evict a terminal game from memory and replay it from SQLite. -- [ ] Change a claimed human slot to computer and verify “My games” association - remains. -- [ ] Verify registration duplicate/session failures roll back account creation - and capacity eviction. -- [ ] Exercise public/private history rules for anonymous, owner, opponent, and - unrelated authenticated clients. -- [ ] Validate imported PGNs with promotions, castling, en passant, custom FEN, - comments, and every supported result. -- [ ] Run Go unit/race tests, HTTP integration scripts, JavaScript syntax/tests, - and browser end-to-end replay navigation in CI. - -## Replay Definition of Done - -- A finished player game survives restart, appears once in its owner's list, - and replays deterministically from the stored initial FEN to the stored final - FEN in both clients. -- A curated game is imported idempotently with source attribution, appears in a - stable public collection, and uses the same replay path as a player game. -- Undo, player reconfiguration, terminal eviction, and concurrent requests - cannot produce a stale result, missing claim, duplicate ply, or mixed history - snapshot. -- Privacy, retention, durable deletion, and degraded-write behavior are - documented and enforced consistently by API, storage, web, and CLI layers. -- Query plans and benchmarks show no redundant indexes or unbounded list scans - at the agreed deployment size. +- [ ] Metrics: write-queue depth, retries, degraded transitions, flush and + replay latency, KDF wait time. +- [ ] Degraded-mode recovery without a restart (replay missing writes from + memory or fail gameplay closed); today recovery requires a restart. +- [ ] Live-game restoration after restart (unfinished games are replay-only). +- [ ] Retention and user-initiated deletion or anonymization of games. +- [ ] Thread request contexts from HTTP handlers into storage calls; today each + call carries its own deadline. +- [ ] Draw and resign flows (D5) for live play, independent of replay. diff --git a/go.mod b/go.mod index 0886ca2..1294bb7 100644 --- a/go.mod +++ b/go.mod @@ -1,33 +1,37 @@ module chess -go 1.26.0 +go 1.27.1 require ( - github.com/go-playground/validator/v10 v10.30.3 - github.com/gofiber/fiber/v2 v2.52.14 + github.com/go-playground/validator/v10 v10.30.5 + github.com/gofiber/fiber/v2 v2.52.15 github.com/google/uuid v1.6.0 - github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8 - github.com/mattn/go-sqlite3 v1.14.48 - golang.org/x/term v0.45.0 + github.com/jackc/pgx/v5 v5.11.0 + github.com/lixenwraith/auth v0.0.0-20260927004242-3b8d4a2946f1 + golang.org/x/term v0.46.0 ) require ( - github.com/andybalholm/brotli v1.2.2 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.14 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/universal-translator v0.18.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/klauspost/compress v1.19.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/leodido/go-urn v1.5.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect - github.com/mattn/go-isatty v0.0.23 // indirect - github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect + github.com/molecule-man/go-brrr v1.1.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.72.0 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + github.com/valyala/fasthttp v1.74.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index f8d4165..e07bb20 100644 --- a/go.sum +++ b/go.sum @@ -1,136 +1,74 @@ -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= -github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= -github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gabriel-vasile/mimetype v1.4.14 h1:8eyElddS5wbWNDG4sIupw+IX2jEjHX2aqAAq/9C3M8s= -github.com/gabriel-vasile/mimetype v1.4.14/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= -github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= -github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= -github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= -github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= -github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= -github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk= -github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE= -github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU= +github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk= +github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4= +github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ= +github.com/gofiber/fiber/v2 v2.52.15 h1:Cov1uKeVPyu9q0jSrN60W+A8XNX+/WK8J7cy5osHLIk= +github.com/gofiber/fiber/v2 v2.52.15/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= -github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= -github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 h1:c7wfyZGdy6RkM/b6mIazoYrAS+3qDL7d9M1CFm2e1VA= -github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226/go.mod h1:1Kfy3ggtRbgrzR+qg99SaeUmmnUZKtur8uOSQsbWaPw= -github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8 h1:AOj9NHdpB3mSRXHYr4iOhFypovIDtzwWMAM80o6JNEY= -github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8/go.mod h1:anmBvIoOyZGcw/TaZPZAvzCYoFOBgVJji5MGP5MFZJE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/lixenwraith/auth v0.0.0-20260927004242-3b8d4a2946f1 h1:pXqSd6vdTuTh4lYdSmsoCpAJohZYAatZguE835Ru4NU= +github.com/lixenwraith/auth v0.0.0-20260927004242-3b8d4a2946f1/go.mod h1:411JUqnbQgy9jgoFV+fH91TfVUEfzYA4dyPv7f4c264= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= -github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= -github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= -github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= -github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= -github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= -github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= -github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/molecule-man/go-brrr v1.1.1 h1:KYwusQhjtV3smALnhmafZf4OBrh7vP4p8O47DyUmmuY= +github.com/molecule-man/go-brrr v1.1.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= -github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= -github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= -github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= -github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= -github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M= -github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU= +github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/api/types.go b/internal/client/api/types.go index 596ebfd..7e94406 100644 --- a/internal/client/api/types.go +++ b/internal/client/api/types.go @@ -57,6 +57,7 @@ type PlayerInfo struct { Level int `json:"level,omitempty"` SearchTime int `json:"searchTime,omitempty"` ClaimedBy string `json:"claimedBy,omitempty"` + Name string `json:"name,omitempty"` } type MoveInfo struct { @@ -120,6 +121,7 @@ type HistoryMove struct { type GameSummary struct { GameID string `json:"gameId"` InitialFEN string `json:"initialFen"` + FinalFEN string `json:"finalFen"` Result string `json:"result,omitempty"` StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` diff --git a/internal/client/command/auth.go b/internal/client/command/auth.go index a6e97c0..053da9d 100644 --- a/internal/client/command/auth.go +++ b/internal/client/command/auth.go @@ -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") return nil -} \ No newline at end of file +} diff --git a/internal/client/command/debug.go b/internal/client/command/debug.go index 6fb695d..225d945 100644 --- a/internal/client/command/debug.go +++ b/internal/client/command/debug.go @@ -89,4 +89,4 @@ func rawRequestHandler(s *session.Session, args []string) error { c := s.GetClient().(*api.Client) return c.RawRequest(method, path, body) -} \ No newline at end of file +} diff --git a/internal/client/command/pass_native.go b/internal/client/command/pass_native.go index 4b27c05..e085f1a 100644 --- a/internal/client/command/pass_native.go +++ b/internal/client/command/pass_native.go @@ -16,4 +16,4 @@ func readPassword(prompt string) (string, error) { return "", err } return string(bytePassword), nil -} \ No newline at end of file +} diff --git a/internal/client/command/pass_wasm.go b/internal/client/command/pass_wasm.go index 56ec284..5e2209b 100644 --- a/internal/client/command/pass_wasm.go +++ b/internal/client/command/pass_wasm.go @@ -25,4 +25,4 @@ func readPassword(prompt string) (string, error) { return "", err } return "", fmt.Errorf("no input received") -} \ No newline at end of file +} diff --git a/internal/client/command/registry.go b/internal/client/command/registry.go index 83ab85c..59d5aa3 100644 --- a/internal/client/command/registry.go +++ b/internal/client/command/registry.go @@ -171,4 +171,4 @@ func exitHandler(s *session.Session, args []string) error { // Exit is handled in main loop, this is just for consistency display.Println(display.Cyan, "Goodbye!\n") return nil -} \ No newline at end of file +} diff --git a/internal/client/display/board.go b/internal/client/display/board.go index 58484c8..f81bce7 100644 --- a/internal/client/display/board.go +++ b/internal/client/display/board.go @@ -50,4 +50,4 @@ func ColorForTurn(turn string) string { return Blue + "White" + Reset } return Red + "Black" + Reset -} \ No newline at end of file +} diff --git a/internal/client/display/colors.go b/internal/client/display/colors.go index 07cf4ab..7ac7ef1 100644 --- a/internal/client/display/colors.go +++ b/internal/client/display/colors.go @@ -49,4 +49,4 @@ func (b *Builder) String() string { // Prompt returns a colored prompt string func Prompt(text string) string { return Yellow + text + Yellow + " > " + Reset -} \ No newline at end of file +} diff --git a/internal/client/display/format.go b/internal/client/display/format.go index 9644ddf..003684e 100644 --- a/internal/client/display/format.go +++ b/internal/client/display/format.go @@ -13,4 +13,4 @@ func PrettyPrintJSON(v any) { return } fmt.Println(string(data)) -} \ No newline at end of file +} diff --git a/internal/client/session/session.go b/internal/client/session/session.go index 9a8a222..e385647 100644 --- a/internal/client/session/session.go +++ b/internal/client/session/session.go @@ -40,4 +40,4 @@ func (s *Session) SetGameState(game any) { } } func (s *Session) SetPlayerColor(color string) { s.PlayerColor = color } -func (s *Session) GetPlayerColor() string { return s.PlayerColor } \ No newline at end of file +func (s *Session) GetPlayerColor() string { return s.PlayerColor } diff --git a/internal/server/board/board.go b/internal/server/board/board.go index 5eb8b28..5a112e2 100644 --- a/internal/server/board/board.go +++ b/internal/server/board/board.go @@ -116,4 +116,4 @@ func (b *Board) GetPieceAt(square string) byte { file := square[0] - 'a' rank := '8' - square[1] return b.squares[rank][file] -} \ No newline at end of file +} diff --git a/internal/server/core/api.go b/internal/server/core/api.go index cec4aa3..4f21aaa 100644 --- a/internal/server/core/api.go +++ b/internal/server/core/api.go @@ -69,10 +69,12 @@ type HistoryMove struct { } // GameSummary is intentionally sufficient for a client-side game picker; the -// full move list remains on the per-game history endpoint. +// full move list remains on the per-game history endpoint. FinalFEN is the +// position after the last stored move (the initial FEN when there is none). type GameSummary struct { GameID string `json:"gameId"` InitialFEN string `json:"initialFen"` + FinalFEN string `json:"finalFen"` Result string `json:"result,omitempty"` StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` diff --git a/internal/server/core/player.go b/internal/server/core/player.go index 075c5cc..1f605b1 100644 --- a/internal/server/core/player.go +++ b/internal/server/core/player.go @@ -19,6 +19,7 @@ type Player struct { Level int `json:"level,omitempty"` // Only for computer SearchTime int `json:"searchTime,omitempty"` // Only for computer ClaimedBy string `json:"claimedBy,omitempty"` // UserID that claimed this slot + Name string `json:"name,omitempty"` // Claimant's username when the claim was stored (history only) } // PlayerConfig for API requests and configuration @@ -82,4 +83,4 @@ func OppositeColor(c Color) Color { return ColorBlack } return ColorWhite -} \ No newline at end of file +} diff --git a/internal/server/engine/engine.go b/internal/server/engine/engine.go index 8b541e5..36193c4 100644 --- a/internal/server/engine/engine.go +++ b/internal/server/engine/engine.go @@ -284,4 +284,3 @@ func (u *UCI) Close() error { u.killLocked() return nil } - diff --git a/internal/server/game/game.go b/internal/server/game/game.go index 4c563df..499117b 100644 --- a/internal/server/game/game.go +++ b/internal/server/game/game.go @@ -26,11 +26,12 @@ type MoveResult struct { } type Game struct { - snapshots []Snapshot - players map[core.Color]*core.Player - state core.State - lastResult *MoveResult - endTimeUTC *time.Time + snapshots []Snapshot + players map[core.Color]*core.Player + state core.State + lastResult *MoveResult + endTimeUTC *time.Time + lastActivity time.Time // last create, move, undo, reconfiguration, or state change } // View is an immutable copy of the state needed by processors and transports. @@ -133,10 +134,6 @@ func (g *Game) SetLastResult(result *MoveResult) { g.lastResult = © } -func (g *Game) LastResult() *MoveResult { - return g.lastResult -} - // CurrentSnapshot returns the latest game snapshot func (g *Game) CurrentSnapshot() Snapshot { return g.snapshots[len(g.snapshots)-1] @@ -217,11 +214,8 @@ func (g *Game) State() core.State { return g.state } -func (g *Game) SetState(s core.State) { - g.SetStateAt(s, time.Now().UTC()) -} - func (g *Game) SetStateAt(s core.State, at time.Time) { + g.Touch(at) if s.IsTerminal() { if !g.state.IsTerminal() || g.endTimeUTC == nil { ended := at.UTC() @@ -233,6 +227,28 @@ func (g *Game) SetStateAt(s core.State, at time.Time) { g.state = s } +// Touch records activity at the given time; earlier times are ignored. +func (g *Game) Touch(at time.Time) { + if at.After(g.lastActivity) { + g.lastActivity = at + } +} + +// LastActivity returns the latest time passed to Touch. +func (g *Game) LastActivity() time.Time { + return g.lastActivity +} + +// IsClaimed reports whether a registered user claimed either slot. +func (g *Game) IsClaimed() bool { + for _, player := range g.players { + if player != nil && player.ClaimedBy != "" { + return true + } + } + return false +} + func (g *Game) EndTimeUTC() *time.Time { if g.endTimeUTC == nil { return nil @@ -278,11 +294,6 @@ func (g *Game) GetSlotOwner(color core.Color) string { return player.ClaimedBy } -// IsSlotClaimedBy checks if a specific user owns the slot -func (g *Game) IsSlotClaimedBy(color core.Color, userID string) bool { - return g.GetSlotOwner(color) == userID -} - // HasComputerPlayer returns true if at least one player is computer func (g *Game) HasComputerPlayer() bool { white := g.players[core.ColorWhite] diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index a9c456c..77425bf 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -91,22 +91,22 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error { req.Email = strings.ToLower(req.Email) } - // Create the user and initial session atomically (temp by default via API). - user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password, false) + // Create the user and initial session atomically. + user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password) if err != nil { if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) { return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable, }) } - if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrPermanentSlotsFull) { + if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrAuthBusy) { return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ Error: "registration temporarily unavailable", Code: core.ErrResourceLimit, Details: err.Error(), }) } - if strings.Contains(err.Error(), "already exists") { + if errors.Is(err, service.ErrUserExists) { return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{ Error: "user already exists", Code: core.ErrInvalidRequest, @@ -194,6 +194,11 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error { Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable, }) } + if errors.Is(err, service.ErrAuthBusy) { + return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ + Error: "authentication temporarily unavailable", Code: core.ErrResourceLimit, + }) + } return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{ Error: "invalid credentials", Code: core.ErrInvalidRequest, @@ -214,7 +219,7 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error { UserID: user.UserID, Username: user.Username, Email: user.Email, - ExpiresAt: time.Now().Add(7 * 24 * time.Hour), + ExpiresAt: time.Now().Add(service.SessionTTL), }) } diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index c39c436..55cfca8 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "strconv" - "strings" "time" "chess/internal/server/core" @@ -30,21 +29,58 @@ func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandle return &HTTPHandler{proc: proc, svc: svc} } -func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRequests bool) *fiber.App { - // Create handler - h := NewHTTPHandler(proc, svc) +// Options configures the API application. +type Options struct { + DevMode bool + LogRequests bool + // TrustedProxies lists reverse-proxy IPs or CIDRs whose ProxyHeader value + // is the client address. Empty means the TCP peer is the client. + TrustedProxies []string + // ProxyHeader names a single-address header the proxy overwrites, such as + // X-Real-IP (nginx: proxy_set_header X-Real-IP $remote_addr). Avoid + // X-Forwarded-For: its first entry is supplied by the client. + ProxyHeader string +} - // Initialize Fiber app - app := fiber.New(fiber.Config{ +// appConfig returns the Fiber configuration. Client-IP resolution only honors +// ProxyHeader on connections from a trusted proxy, so rate-limit keys cannot be +// chosen by a client that reaches the API directly. +func appConfig(opts Options) fiber.Config { + config := fiber.Config{ ErrorHandler: customErrorHandler, ReadTimeout: 15 * time.Second, WriteTimeout: 35 * time.Second, IdleTimeout: 60 * time.Second, - }) + } + if len(opts.TrustedProxies) > 0 && opts.ProxyHeader != "" { + config.EnableTrustedProxyCheck = true + config.TrustedProxies = opts.TrustedProxies + config.ProxyHeader = opts.ProxyHeader + config.EnableIPValidation = true + } + return config +} + +// clientIP is the rate-limit key: the proxy-reported address from a trusted +// proxy, otherwise the TCP peer. +func clientIP(c *fiber.Ctx) string { + if ip := c.IP(); ip != "" { + return ip + } + return c.Context().RemoteIP().String() +} + +func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) *fiber.App { + // Create handler + h := NewHTTPHandler(proc, svc) + devMode := opts.DevMode + + // Initialize Fiber app + app := fiber.New(appConfig(opts)) // Global middleware (order matters) app.Use(recover.New()) - if logRequests { + if opts.LogRequests { app.Use(logger.New(logger.Config{ Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n", TimeFormat: time.RFC3339, @@ -68,11 +104,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe // Register: 5 req/min per IP auth.Post("/register", limiter.New(limiter.Config{ - Max: 5, - Expiration: 1 * time.Minute, - KeyGenerator: func(c *fiber.Ctx) string { - return c.IP() - }, + Max: 5, + Expiration: 1 * time.Minute, + KeyGenerator: clientIP, LimitReached: func(c *fiber.Ctx) error { return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{ Error: "rate limit exceeded", @@ -84,11 +118,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe // Login: 10 req/min per IP auth.Post("/login", limiter.New(limiter.Config{ - Max: 10, - Expiration: 1 * time.Minute, - KeyGenerator: func(c *fiber.Ctx) string { - return c.IP() - }, + Max: 10, + Expiration: 1 * time.Minute, + KeyGenerator: clientIP, LimitReached: func(c *fiber.Ctx) error { return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{ Error: "rate limit exceeded", @@ -113,17 +145,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe maxReq = rateLimitRate * 2 } api.Use(limiter.New(limiter.Config{ - Max: maxReq, - Expiration: 1 * time.Second, - KeyGenerator: func(c *fiber.Ctx) string { - if xff := c.Get("X-Forwarded-For"); xff != "" { - if idx := strings.Index(xff, ","); idx != -1 { - return strings.TrimSpace(xff[:idx]) - } - return xff - } - return c.IP() - }, + Max: maxReq, + Expiration: 1 * time.Second, + KeyGenerator: clientIP, LimitReached: func(c *fiber.Ctx) error { return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{ Error: "rate limit exceeded", diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go new file mode 100644 index 0000000..0cdb3a4 --- /dev/null +++ b/internal/server/http/handler_test.go @@ -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) + } + } +} diff --git a/internal/server/http/middleware.go b/internal/server/http/middleware.go index e0cd957..3b293be 100644 --- a/internal/server/http/middleware.go +++ b/internal/server/http/middleware.go @@ -2,12 +2,12 @@ package http import ( "errors" - "strings" "chess/internal/server/core" "chess/internal/server/service" "github.com/gofiber/fiber/v2" + "github.com/lixenwraith/auth" ) // TokenValidator validates JWT tokens @@ -16,69 +16,59 @@ type TokenValidator func(token string) (userID string, claims map[string]any, er // AuthRequired enforces JWT authentication for protected endpoints func AuthRequired(validateToken TokenValidator) fiber.Handler { return func(c *fiber.Ctx) error { - token := extractBearerToken(c.Get("Authorization")) - if token == "" { + header := c.Get(fiber.HeaderAuthorization) + if header == "" { return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{ Error: "missing authorization token", Code: core.ErrUnauthorized, }) } - - userID, claims, err := validateToken(token) + token, err := auth.ParseBearerToken(header) 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 c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{ - Error: "invalid or expired token", - Code: core.ErrUnauthorized, - }) + return invalidToken(c) } - c.Locals("userID", userID) - if sessionID, ok := claims["session_id"].(string); ok { - c.Locals("sessionID", sessionID) - } - return c.Next() + return authenticate(c, validateToken, token) } } -// OptionalAuth permits an absent token but rejects an invalid token instead of -// silently downgrading an intended authenticated request to anonymous access. +// OptionalAuth permits an absent Authorization header but rejects a malformed +// or invalid one instead of silently downgrading an intended authenticated +// request to anonymous access. func OptionalAuth(validateToken TokenValidator) fiber.Handler { return func(c *fiber.Ctx) error { - token := extractBearerToken(c.Get("Authorization")) - if token == "" { + header := c.Get(fiber.HeaderAuthorization) + if header == "" { return c.Next() } - - userID, claims, err := validateToken(token) + token, err := auth.ParseBearerToken(header) 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 c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{ - Error: "invalid or expired token", Code: core.ErrUnauthorized, + return invalidToken(c) + } + return authenticate(c, validateToken, token) + } +} + +func authenticate(c *fiber.Ctx, validateToken TokenValidator, token string) error { + userID, claims, err := validateToken(token) + if err != nil { + if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) { + return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ + Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable, }) } - - c.Locals("userID", userID) - if sessionID, ok := claims["session_id"].(string); ok { - c.Locals("sessionID", sessionID) - } - return c.Next() + return invalidToken(c) } + + c.Locals("userID", userID) + if sessionID, ok := claims["session_id"].(string); ok { + c.Locals("sessionID", sessionID) + } + return c.Next() } -// extractBearerToken extracts JWT token from Authorization header -func extractBearerToken(header string) string { - const prefix = "Bearer " - if !strings.HasPrefix(header, prefix) { - return "" - } - return strings.TrimPrefix(header, prefix) +func invalidToken(c *fiber.Ctx) error { + return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{ + Error: "invalid or expired token", Code: core.ErrUnauthorized, + }) } diff --git a/internal/server/http/middleware_test.go b/internal/server/http/middleware_test.go index 5ff6bb1..e7af2ab 100644 --- a/internal/server/http/middleware_test.go +++ b/internal/server/http/middleware_test.go @@ -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) + } + } + } +} diff --git a/internal/server/http/validator.go b/internal/server/http/validator.go index 54fc87f..1bc618c 100644 --- a/internal/server/http/validator.go +++ b/internal/server/http/validator.go @@ -96,7 +96,13 @@ func validationMiddleware(c *fiber.Ctx) error { return c.Next() } +// isValidUUID accepts only the canonical 36-character form the server issues. +// uuid.Parse also accepts braced, URN, and unhyphenated forms, which never +// match an in-memory game and are rejected by the database's uuid input. func isValidUUID(s string) bool { + if len(s) != 36 { + return false + } _, err := uuid.Parse(s) return err == nil -} \ No newline at end of file +} diff --git a/internal/server/processor/command.go b/internal/server/processor/command.go index 176f5c1..966b57f 100644 --- a/internal/server/processor/command.go +++ b/internal/server/processor/command.go @@ -83,4 +83,4 @@ func NewGetBoardCommand(gameID string) Command { Type: CmdGetBoard, GameID: gameID, } -} \ No newline at end of file +} diff --git a/internal/server/processor/processor.go b/internal/server/processor/processor.go index 33ace8b..5e191b5 100644 --- a/internal/server/processor/processor.go +++ b/internal/server/processor/processor.go @@ -531,25 +531,6 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error { }) } -// determineGameEndState centralized function to determine game end state based on engine evaluation -func (p *Processor) determineGameEndState(lastMoveBy core.Color, searchResult *engine.SearchResult) core.State { - // No legal moves detected - if searchResult.BestMove == "" || searchResult.BestMove == "(none)" { - if searchResult.IsMate { - // It's a checkmate - the side that just moved wins - if lastMoveBy == core.ColorWhite { - return core.StateWhiteWins - } - return core.StateBlackWins - } - // Stalemate - no legal moves but not in check - return core.StateStalemate - } - - // Game continues - return core.StateOngoing -} - // classifyCurrentLocked classifies whatever position is loaded in the // validation engine. Caller holds p.mu, immediately after a SetPosition. func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) { @@ -585,26 +566,6 @@ func (p *Processor) classifyLocked(fen string) (string, core.State, error) { return p.classifyCurrentLocked() } -// checkGameEnd: retry once (second attempt runs on a respawned process), then -// fail SAFE to StateStuck. Leaving a possibly-terminal position Ongoing is the -// original bug class; Stuck is now recoverable via undo (see handleUndoMove). -func (p *Processor) checkGameEnd(gameID, fen string) { - for attempt := 0; attempt < 2; attempt++ { - p.mu.Lock() - _, state, err := p.classifyLocked(fen) - p.mu.Unlock() - if err == nil { - if state != core.StateOngoing { - p.svc.UpdateGameState(gameID, state) - } - return - } - slog.Warn("game end-state check failed", - "game_id", gameID, "attempt", attempt+1, "error", err) - } - p.svc.UpdateGameState(gameID, core.StateStuck) -} - // buildGameResponse constructs standard game response func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse { resp := core.GameResponse{ diff --git a/internal/server/service/game.go b/internal/server/service/game.go index ef1ce1d..dec8bf8 100644 --- a/internal/server/service/game.go +++ b/internal/server/service/game.go @@ -127,6 +127,7 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl } g.UpdatePlayers(whitePlayer, blackPlayer) + g.Touch(time.Now().UTC()) if oldHasComputer != newHasComputer { if newHasComputer { s.computerGames.Add(1) @@ -302,20 +303,6 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error { return nil } -// SetLastMoveResult stores metadata about the last move -func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error { - s.mu.Lock() - defer s.mu.Unlock() - - g, ok := s.games[gameID] - if !ok { - return fmt.Errorf("%w: %s", ErrGameNotFound, gameID) - } - - g.SetLastResult(result) - return nil -} - // UndoMoves removes the specified number of moves from game history func (s *Service) UndoMoves(gameID string, count int) error { s.mu.Lock() @@ -334,6 +321,7 @@ func (s *Service) UndoMoves(gameID string, count int) error { if err := g.UndoMoves(count); err != nil { return err } + g.Touch(time.Now().UTC()) // Notify waiting clients about the undo s.waiter.NotifyGame(gameID, len(g.Moves()), g.State()) diff --git a/internal/server/service/game_test.go b/internal/server/service/game_test.go index d9107d0..3f9167e 100644 --- a/internal/server/service/game_test.go +++ b/internal/server/service/game_test.go @@ -2,21 +2,24 @@ package service import ( "errors" - "path/filepath" "testing" "time" "chess/internal/server/core" "chess/internal/server/game" "chess/internal/server/storage" + "chess/internal/server/storage/pgtest" + + "github.com/google/uuid" ) func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) { svc := newPersistentTestService(t) + gameID, userID := uuid.NewString(), uuid.NewString() white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) if err := svc.CreateGame( - "game-1", white, black, "initial", core.ColorWhite, core.StateOngoing, + gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, ); err != nil { t.Fatal(err) } @@ -24,53 +27,54 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC) commit := MoveCommit{ ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite, - ActorUserID: "user-1", MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended, + ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended, Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins}, } - if err := svc.ApplyMoveWithState("game-1", commit); err != nil { + if err := svc.ApplyMoveWithState(gameID, commit); err != nil { t.Fatal(err) } - view, err := svc.GetGameView("game-1") + view, err := svc.GetGameView(gameID) if err != nil { t.Fatal(err) } - if view.WhitePlayer.ClaimedBy != "user-1" || view.State != core.StateWhiteWins { + if view.WhitePlayer.ClaimedBy != userID || view.State != core.StateWhiteWins { t.Fatalf("in-memory move did not settle atomically: %+v", view) } - history, err := svc.GetGameHistory("game-1") + history, err := svc.GetGameHistory(gameID) if err != nil { t.Fatal(err) } - if history.Players.White.ClaimedBy != "user-1" || history.Result != "white_wins" || len(history.Moves) != 1 { + if history.Players.White.ClaimedBy != userID || history.Result != "white_wins" || len(history.Moves) != 1 { t.Fatalf("history did not settle atomically: %+v", history) } - if err := svc.ApplyMoveWithState("game-1", commit); !errors.Is(err, ErrGameChanged) { + if err := svc.ApplyMoveWithState(gameID, commit); !errors.Is(err, ErrGameChanged) { t.Fatalf("stale move error = %v, want ErrGameChanged", err) } } func TestUndoClearsDurableTerminalResult(t *testing.T) { svc := newPersistentTestService(t) + gameID := uuid.NewString() white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) if err := svc.CreateGame( - "game-1", white, black, "initial", core.ColorWhite, core.StateOngoing, + gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, ); err != nil { t.Fatal(err) } - if err := svc.ApplyMoveWithState("game-1", MoveCommit{ + if err := svc.ApplyMoveWithState(gameID, MoveCommit{ ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite, MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate, }); err != nil { t.Fatal(err) } - if err := svc.UndoMoves("game-1", 1); err != nil { + if err := svc.UndoMoves(gameID, 1); err != nil { t.Fatal(err) } - history, err := svc.GetGameHistory("game-1") + history, err := svc.GetGameHistory(gameID) if err != nil { t.Fatal(err) } @@ -81,12 +85,13 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) { func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.T) { svc := newPersistentTestService(t) + gameID, userID := uuid.NewString(), uuid.NewString() white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) - white.ID = "user-1" - white.ClaimedBy = "user-1" + white.ID = userID + white.ClaimedBy = userID black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) if err := svc.CreateGame( - "game-1", white, black, "initial", core.ColorWhite, core.StateOngoing, + gameID, white, black, "initial", core.ColorWhite, core.StateOngoing, ); err != nil { t.Fatal(err) } @@ -95,15 +100,15 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing. replacementBlack := core.NewPlayer( core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack, ) - if err := svc.UpdatePlayers("game-1", replacementWhite, replacementBlack); err != nil { + if err := svc.UpdatePlayers(gameID, replacementWhite, replacementBlack); err != nil { t.Fatal(err) } - history, err := svc.GetGameHistory("game-1") + history, err := svc.GetGameHistory(gameID) if err != nil { t.Fatal(err) } - if history.Players.White.ID != "user-1" || history.Players.White.ClaimedBy != "user-1" { + if history.Players.White.ID != userID || history.Players.White.ClaimedBy != userID { t.Fatalf("white ownership was replaced: %+v", history.Players.White) } if history.Players.Black.Type != core.PlayerComputer || history.Players.Black.Level != 12 { @@ -113,48 +118,101 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing. computerWhite := core.NewPlayer( core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite, ) - if err := svc.UpdatePlayers("game-1", computerWhite, replacementBlack); err != nil { + if err := svc.UpdatePlayers(gameID, computerWhite, replacementBlack); err != nil { t.Fatal(err) } - history, err = svc.GetGameHistory("game-1") + history, err = svc.GetGameHistory(gameID) if err != nil { t.Fatal(err) } - if history.Players.White.ClaimedBy != "user-1" { + if history.Players.White.ClaimedBy != userID { t.Fatalf("player-type change discarded historical ownership: %+v", history.Players.White) } } func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) { svc := newPersistentTestService(t) + gameID := uuid.NewString() svc.SetFinishedGameTTL(time.Nanosecond) white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) if err := svc.CreateGame( - "game-1", white, black, "terminal", core.ColorWhite, core.StateStalemate, + gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate, ); err != nil { t.Fatal(err) } - svc.cleanupFinishedGames(time.Now().UTC().Add(time.Second)) - if _, err := svc.GetGameView("game-1"); !errors.Is(err, ErrGameNotFound) { + svc.cleanupGames(time.Now().UTC().Add(time.Second)) + if _, err := svc.GetGameView(gameID); !errors.Is(err, ErrGameNotFound) { t.Fatalf("terminal game remains in memory: %v", err) } - if history, err := svc.GetGameHistory("game-1"); err != nil || history.Result != "stalemate" { + if history, err := svc.GetGameHistory(gameID); err != nil || history.Result != "stalemate" { t.Fatalf("durable history was removed: history=%+v err=%v", history, err) } } +func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) { + svc := newPersistentTestService(t) + svc.SetFinishedGameTTL(0) + user, _, err := svc.RegisterUser("alice", "", "Password1") + if err != nil { + t.Fatal(err) + } + + anonymous, claimed := uuid.NewString(), uuid.NewString() + for gameID, claimant := range map[string]string{anonymous: "", claimed: user.UserID} { + white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite) + if claimant != "" { + white.ID, white.ClaimedBy = claimant, claimant + } + black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack) + if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing); err != nil { + t.Fatal(err) + } + } + history, err := svc.GetGameHistory(claimed) + if err != nil || history.Players.White.Name != "alice" { + t.Fatalf("claimed history name = %+v, %v", history, err) + } + + // Within the retention window both games stay loaded and stored. + svc.cleanupGames(time.Now().UTC().Add(time.Hour)) + for _, gameID := range []string{anonymous, claimed} { + if _, err := svc.GetGameView(gameID); err != nil { + t.Fatalf("game %s evicted early: %v", gameID, err) + } + } + + // Past it, the anonymous game leaves memory and the database; the + // registered user's game is untouched. + svc.cleanupGames(time.Now().UTC().Add(AnonymousGameTTL + time.Hour)) + if _, err := svc.GetGameView(anonymous); !errors.Is(err, ErrGameNotFound) { + t.Fatalf("idle anonymous game still loaded: %v", err) + } + if _, err := svc.GetGameHistory(anonymous); !errors.Is(err, ErrGameNotFound) { + t.Fatalf("idle anonymous game still stored: %v", err) + } + if _, err := svc.GetGameView(claimed); err != nil { + t.Fatalf("claimed game evicted: %v", err) + } + if _, err := svc.GetGameHistory(claimed); err != nil { + t.Fatalf("claimed game deleted: %v", err) + } +} + func newPersistentTestService(t *testing.T) *Service { t.Helper() - store, err := storage.NewStore(filepath.Join(t.TempDir(), "chess.db"), false) + store, err := storage.NewStore(pgtest.DSN(t)) if err != nil { t.Fatal(err) } if err := store.InitDB(); err != nil { t.Fatal(err) } - svc := New(store, []byte("test-secret-test-secret-test-secret")) + svc, err := New(store, testJWTSecret) + if err != nil { + t.Fatal(err) + } t.Cleanup(func() { if err := svc.Shutdown(time.Second); err != nil { t.Errorf("shutdown: %v", err) diff --git a/internal/server/service/history.go b/internal/server/service/history.go index e74b894..75284b6 100644 --- a/internal/server/service/history.go +++ b/internal/server/service/history.go @@ -82,6 +82,7 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList StartTimeUTC: record.StartTimeUTC, EndTimeUTC: record.EndTimeUTC, MoveCount: record.MoveCount, + FinalFEN: record.FinalFEN, Players: playersResponse(record.GameRecord), }) } @@ -100,10 +101,12 @@ func playersResponse(record storage.GameRecord) core.PlayersResponse { White: &core.Player{ ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType), Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy, + Name: record.WhiteName, }, Black: &core.Player{ ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType), Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy, + Name: record.BlackName, }, } } diff --git a/internal/server/service/service.go b/internal/server/service/service.go index 6792746..f3fea23 100644 --- a/internal/server/service/service.go +++ b/internal/server/service/service.go @@ -1,7 +1,6 @@ package service import ( - "chess/internal/server/core" "context" "errors" "fmt" @@ -10,41 +9,62 @@ import ( "sync/atomic" "time" + "chess/internal/server/core" "chess/internal/server/game" "chess/internal/server/storage" + + "github.com/lixenwraith/auth" ) const ( MaxComputerGames = 10 - MaxUsers = 100 - PermanentSlots = 10 - TempUserTTL = 24 * time.Hour + DefaultMaxUsers = 100 SessionTTL = 7 * 24 * time.Hour CleanupJobInterval = 1 * time.Hour FinishedGameTTL = 1 * time.Hour + // AnonymousGameTTL is how long a game no registered user claimed survives + // after its last activity, in memory and in the database. + AnonymousGameTTL = 24 * time.Hour ) // Service coordinates game state, user management, and storage type Service struct { games map[string]*game.Game mu sync.RWMutex - userMu sync.Mutex store *storage.Store - jwtSecret []byte + jwt *auth.JWT + kdf chan struct{} // Argon2id concurrency slots waiter *WaitRegistry computerGames atomic.Int32 // Active games with computer players finishedTTL time.Duration + anonymousTTL time.Duration + maxUsers atomic.Int64 } -// New creates a new service instance with optional storage -func New(store *storage.Store, jwtSecret []byte) *Service { - return &Service{ - games: make(map[string]*game.Game), - store: store, - jwtSecret: jwtSecret, - waiter: NewWaitRegistry(), - finishedTTL: FinishedGameTTL, +// New creates a service with optional storage. jwtSecret signs session tokens +// and must hold at least 32 bytes of key material. +func New(store *storage.Store, jwtSecret []byte) (*Service, error) { + manager, err := auth.NewJWT(jwtSecret, + auth.WithIssuer(JWTIssuer), + auth.WithAudience([]string{JWTAudience}), + auth.WithTokenLifetime(SessionTTL), + // Tokens are minted and verified by this process: no clock skew. + auth.WithLeeway(0), + ) + if err != nil { + return nil, fmt.Errorf("configure JWT: %w", err) } + s := &Service{ + games: make(map[string]*game.Game), + store: store, + jwt: manager, + kdf: make(chan struct{}, MaxConcurrentKDF), + waiter: NewWaitRegistry(), + finishedTTL: FinishedGameTTL, + anonymousTTL: AnonymousGameTTL, + } + s.maxUsers.Store(DefaultMaxUsers) + return s, nil } // SetFinishedGameTTL configures how long terminal games remain in memory. @@ -56,6 +76,22 @@ func (s *Service) SetFinishedGameTTL(ttl time.Duration) { s.mu.Unlock() } +// SetAnonymousGameTTL configures how long games without a registered player +// survive after their last activity. Such games are unloaded from memory and +// deleted from the database once idle this long. A non-positive duration +// keeps them indefinitely. +func (s *Service) SetAnonymousGameTTL(ttl time.Duration) { + s.mu.Lock() + s.anonymousTTL = ttl + s.mu.Unlock() +} + +// SetMaxUsers caps the number of accounts public registration may create; +// zero removes the cap. Accounts created through the CLI are not limited. +func (s *Service) SetMaxUsers(limit int) { + s.maxUsers.Store(int64(max(limit, 0))) +} + // GetStorageHealth returns the storage component status func (s *Service) GetStorageHealth() string { if s.store == nil { @@ -84,60 +120,11 @@ func (s *Service) CanCreateComputerGame() bool { return s.computerGames.Load() < MaxComputerGames } -// IncrementComputerGames increments the computer game counter -func (s *Service) IncrementComputerGames() { - s.computerGames.Add(1) -} - -// DecrementComputerGames decrements the computer game counter -func (s *Service) DecrementComputerGames() { - s.computerGames.Add(-1) -} - // GetComputerGameCount returns current computer game count func (s *Service) GetComputerGameCount() int32 { return s.computerGames.Load() } -// ClaimGameSlot claims a player slot for a user -func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string) error { - if userID == "" { - return errors.New("claimant user ID is required") - } - s.mu.Lock() - defer s.mu.Unlock() - - g, ok := s.games[gameID] - if !ok { - return fmt.Errorf("game not found: %s", gameID) - } - - if err := g.ClaimSlot(color, userID); err != nil { - return err - } - if s.store != nil { - if err := s.store.RecordSlotClaim(gameID, color.String(), userID); err != nil { - slog.Error("failed to queue slot claim persistence", - "game_id", gameID, "color", color.String(), "error", err) - } - } - slog.Debug("game slot claimed", "game_id", gameID, "color", color.String(), "user_id", userID) - return nil -} - -// GetSlotOwner returns the user who claimed a slot -func (s *Service) GetSlotOwner(gameID string, color core.Color) (string, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - g, ok := s.games[gameID] - if !ok { - return "", fmt.Errorf("game not found: %s", gameID) - } - - return g.GetSlotOwner(color), nil -} - // Shutdown gracefully shuts down the service func (s *Service) Shutdown(timeout time.Duration) error { var errs []error @@ -159,7 +146,8 @@ func (s *Service) Shutdown(timeout time.Duration) error { return errors.Join(errs...) } -// RunCleanupJob runs periodic cleanup of expired users and sessions +// RunCleanupJob periodically removes expired sessions, unloads idle games, and +// deletes anonymous games past their retention. func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) { s.cleanupExpired() ticker := time.NewTicker(interval) @@ -176,49 +164,62 @@ func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) { } func (s *Service) cleanupExpired() { + now := time.Now().UTC() if s.store != nil { - if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil { - slog.Error("cleanup failed to delete expired users", "error", err) - } else if deleted > 0 { - slog.Info("cleanup deleted expired temporary users", "count", deleted) - } - if deleted, err := s.store.DeleteExpiredSessions(); err != nil { slog.Error("cleanup failed to delete expired sessions", "error", err) } else if deleted > 0 { slog.Info("cleanup deleted expired sessions", "count", deleted) } } - - s.cleanupFinishedGames(time.Now().UTC()) + s.cleanupGames(now) } -func (s *Service) cleanupFinishedGames(now time.Time) { +// cleanupGames unloads terminal games after the finished-game TTL and +// unclaimed games idle past the anonymous TTL, then queues deletion of +// anonymous games idle past that TTL. The set of games still loaded is taken +// under the same lock as the eviction and excluded from deletion, so no live +// game can lose its row while writes for it are still possible. +func (s *Service) cleanupGames(now time.Time) { s.mu.Lock() - if s.finishedTTL <= 0 { - s.mu.Unlock() - return - } - cutoff := now.Add(-s.finishedTTL) - removed := make([]string, 0) + finishedCutoff := now.Add(-s.finishedTTL) + anonymousTTL := s.anonymousTTL + anonymousCutoff := now.Add(-anonymousTTL) + var finished, idle []string + live := make([]string, 0, len(s.games)) for gameID, g := range s.games { ended := g.EndTimeUTC() - if !g.State().IsTerminal() || ended == nil || ended.After(cutoff) { + switch { + case s.finishedTTL > 0 && g.State().IsTerminal() && ended != nil && ended.Before(finishedCutoff): + finished = append(finished, gameID) + case anonymousTTL > 0 && !g.IsClaimed() && g.State() != core.StatePending && + g.LastActivity().Before(anonymousCutoff): + idle = append(idle, gameID) + default: + live = append(live, gameID) continue } if g.HasComputerPlayer() { s.computerGames.Add(-1) } delete(s.games, gameID) - removed = append(removed, gameID) } s.mu.Unlock() - for _, gameID := range removed { + for _, gameID := range append(finished, idle...) { s.waiter.RemoveGame(gameID) } - if len(removed) > 0 { + if len(finished) > 0 { slog.Info("cleanup evicted terminal games from memory", - "count", len(removed), "retention", s.finishedTTL) + "count", len(finished), "retention", s.finishedTTL) + } + if len(idle) > 0 { + slog.Info("cleanup evicted idle anonymous games from memory", + "count", len(idle), "retention", anonymousTTL) + } + if s.store != nil && anonymousTTL > 0 { + if err := s.store.DeleteAnonymousGames(anonymousCutoff, live); err != nil { + slog.Error("cleanup failed to queue anonymous game deletion", "error", err) + } } } diff --git a/internal/server/service/user.go b/internal/server/service/user.go index 948e952..da3dd2a 100644 --- a/internal/server/service/user.go +++ b/internal/server/service/user.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "strings" + "sync" "time" "chess/internal/server/storage" @@ -18,122 +19,129 @@ var ( ErrStorageDisabled = errors.New("storage disabled") ErrStorageUnavailable = errors.New("storage unavailable") ErrAtCapacity = errors.New("at capacity") - ErrPermanentSlotsFull = errors.New("permanent slots full") + ErrUserExists = errors.New("username or email already exists") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrAuthBusy = errors.New("authentication capacity exhausted") ) +const ( + // JWTIssuer and JWTAudience scope chess tokens so a token minted by another + // service sharing a key is rejected. + JWTIssuer = "chess-server" + JWTAudience = "chess-api" + + // MaxConcurrentKDF bounds simultaneous Argon2id derivations (64 MiB each at + // the auth defaults), capping password-hashing memory near 256 MiB. + MaxConcurrentKDF = 4 + kdfWaitTimeout = 5 * time.Second +) + +// dummyPasswordHash is verified for unknown identifiers so a login for a +// missing account costs the same Argon2id work as one with a wrong password. +var dummyPasswordHash = sync.OnceValues(func() (string, error) { + return auth.HashPassword(uuid.NewString()) +}) + // User represents a registered user account type User struct { - UserID string - Username string - Email string - AccountType string - CreatedAt time.Time - ExpiresAt *time.Time + UserID string + Username string + Email string + CreatedAt time.Time } -// CreateUser creates new user with registration limits enforcement -func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) { - user, _, err := s.createUser(username, email, password, permanent, false) - return user, err +// acquireKDF reserves one Argon2id slot, waiting at most kdfWaitTimeout. +func (s *Service) acquireKDF() (release func(), err error) { + timer := time.NewTimer(kdfWaitTimeout) + defer timer.Stop() + select { + case s.kdf <- struct{}{}: + return func() { <-s.kdf }, nil + case <-timer.C: + return nil, ErrAuthBusy + } } -// RegisterUser creates the account and its initial session in one SQLite +func (s *Service) hashPassword(password string) (string, error) { + release, err := s.acquireKDF() + if err != nil { + return "", err + } + defer release() + return auth.HashPassword(password) +} + +func (s *Service) verifyPassword(password, hash string) error { + release, err := s.acquireKDF() + if err != nil { + return err + } + defer release() + return auth.VerifyPassword(password, hash) +} + +// RegisterUser creates the account and its initial session in one database // transaction, so a successful registration always returns a usable account. -func (s *Service) RegisterUser(username, email, password string, permanent bool) (*User, string, error) { - return s.createUser(username, email, password, permanent, true) -} - -func (s *Service) createUser( - username, email, password string, - permanent, withSession bool, -) (*User, string, error) { - s.userMu.Lock() - defer s.userMu.Unlock() - +// Registered accounts are identical to CLI-created ones and never expire. +func (s *Service) RegisterUser(username, email, password string) (*User, string, error) { if s.store == nil { return nil, "", ErrStorageDisabled } - // Determine account type - accountType := "temp" - var expiresAt *time.Time - - if permanent { - accountType = "permanent" - } else { - expiry := time.Now().UTC().Add(TempUserTTL) - expiresAt = &expiry - } - - // Hash password - passwordHash, err := auth.HashPassword(password) + // Hash before touching storage; account creation itself is serialized by + // the store, so concurrent registrations only contend for KDF slots. + passwordHash, err := s.hashPassword(password) if err != nil { + if errors.Is(err, ErrAuthBusy) { + return nil, "", err + } return nil, "", fmt.Errorf("failed to hash password: %w", err) } - // Generate unique user ID - userID, err := s.generateUniqueUserID() - if err != nil { - return nil, "", fmt.Errorf("failed to generate unique ID: %w", err) - } + now := time.Now().UTC() - // Create user record + // A random UUIDv4 needs no existence probe; the primary key rejects the + // negligible collision case. + userID := uuid.NewString() user := &User{ - UserID: userID, - Username: username, - Email: email, - AccountType: accountType, - CreatedAt: time.Now().UTC(), - ExpiresAt: expiresAt, + UserID: userID, + Username: strings.ToLower(username), + Email: strings.ToLower(email), + CreatedAt: now, } - record := storage.UserRecord{ UserID: userID, - Username: strings.ToLower(username), - Email: strings.ToLower(email), + Username: user.Username, + Email: user.Email, PasswordHash: passwordHash, - AccountType: accountType, - CreatedAt: user.CreatedAt, - ExpiresAt: expiresAt, + CreatedAt: now, } - - var sessionID string - var session *storage.SessionRecord - if withSession { - sessionID = uuid.New().String() - session = &storage.SessionRecord{ - SessionID: sessionID, - UserID: userID, - CreatedAt: user.CreatedAt, - ExpiresAt: user.CreatedAt.Add(SessionTTL), - } - } - limits := storage.UserLimits{ - MaxUsers: MaxUsers, - PermanentSlots: PermanentSlots, + sessionID := uuid.NewString() + session := &storage.SessionRecord{ + SessionID: sessionID, + UserID: userID, + CreatedAt: now, + ExpiresAt: now.Add(SessionTTL), } + limits := storage.UserLimits{MaxUsers: int(s.maxUsers.Load())} if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil { switch { case errors.Is(err, storage.ErrUserAlreadyExists): - return nil, "", fmt.Errorf("username or email already exists: %w", err) - case errors.Is(err, storage.ErrPermanentCapacity): - return nil, "", fmt.Errorf("%w (%d maximum)", ErrPermanentSlotsFull, PermanentSlots) + return nil, "", ErrUserExists case errors.Is(err, storage.ErrUserCapacity): - return nil, "", fmt.Errorf("%w: no temporary account can be replaced", ErrAtCapacity) + return nil, "", fmt.Errorf("%w: registration is limited to %d accounts", ErrAtCapacity, limits.MaxUsers) default: return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err) } } - slog.Debug("user created", - "user_id", userID, - "account_type", accountType, - "initial_session", withSession, - ) + slog.Debug("user created", "user_id", userID) return user, sessionID, nil } -// AuthenticateUser verifies credentials and creates a new session +// AuthenticateUser verifies credentials and creates a new session. Unknown +// identifiers and wrong passwords both return ErrInvalidCredentials after the +// same Argon2id work. func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) { if s.store == nil { return nil, "", ErrStorageDisabled @@ -141,75 +149,49 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string, var userRecord *storage.UserRecord var err error - - // Check if identifier looks like email if strings.Contains(identifier, "@") { userRecord, err = s.store.GetUserByEmail(identifier) } else { userRecord, err = s.store.GetUserByUsername(identifier) } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err) + } - if err != nil { - auth.HashPassword(password) // Timing attack prevention - if !errors.Is(err, sql.ErrNoRows) { - return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err) + hash := "" + if userRecord != nil { + hash = userRecord.PasswordHash + } else if hash, err = dummyPasswordHash(); err != nil { + return nil, "", fmt.Errorf("prepare credential check: %w", err) + } + if err := s.verifyPassword(password, hash); err != nil || userRecord == nil { + if errors.Is(err, ErrAuthBusy) { + return nil, "", err } - return nil, "", fmt.Errorf("invalid credentials") + return nil, "", ErrInvalidCredentials } - // Verify password - if err := auth.VerifyPassword(password, userRecord.PasswordHash); err != nil { - return nil, "", fmt.Errorf("invalid credentials") - } - - // Check if temp user expired - if userRecord.AccountType == "temp" && userRecord.ExpiresAt != nil { - if time.Now().UTC().After(*userRecord.ExpiresAt) { - return nil, "", fmt.Errorf("account expired") - } - } - - // Create new session (invalidates any existing session) - sessionID := uuid.New().String() - sessionRecord := storage.SessionRecord{ + now := time.Now().UTC() + // Replaces any existing session and records the login time. + sessionID := uuid.NewString() + if err := s.store.CreateSession(storage.SessionRecord{ SessionID: sessionID, UserID: userRecord.UserID, - CreatedAt: time.Now().UTC(), - ExpiresAt: time.Now().UTC().Add(SessionTTL), - } - - if err := s.store.CreateSession(sessionRecord); err != nil { + CreatedAt: now, + ExpiresAt: now.Add(SessionTTL), + }); err != nil { return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err) } slog.Debug("user authenticated", "user_id", userRecord.UserID) - // Update last login - if err := s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC()); err != nil { - slog.Warn("failed to record user login time", "user_id", userRecord.UserID, "error", err) - } - return &User{ - UserID: userRecord.UserID, - Username: userRecord.Username, - Email: userRecord.Email, - AccountType: userRecord.AccountType, - CreatedAt: userRecord.CreatedAt, - ExpiresAt: userRecord.ExpiresAt, + UserID: userRecord.UserID, + Username: userRecord.Username, + Email: userRecord.Email, + CreatedAt: userRecord.CreatedAt, }, sessionID, nil } -// ValidateSession checks if a session is valid -func (s *Service) ValidateSession(sessionID string) (bool, error) { - if s.store == nil { - return false, ErrStorageDisabled - } - valid, err := s.store.IsSessionValid(sessionID) - if err != nil { - return false, fmt.Errorf("%w: validate session: %v", ErrStorageUnavailable, err) - } - return valid, nil -} - // InvalidateSession removes a session (logout) func (s *Service) InvalidateSession(sessionID string) error { if s.store == nil { @@ -236,34 +218,23 @@ func (s *Service) GetUserByID(userID string) (*User, error) { } return &User{ - UserID: userRecord.UserID, - Username: userRecord.Username, - Email: userRecord.Email, - AccountType: userRecord.AccountType, - CreatedAt: userRecord.CreatedAt, - ExpiresAt: userRecord.ExpiresAt, + UserID: userRecord.UserID, + Username: userRecord.Username, + Email: userRecord.Email, + CreatedAt: userRecord.CreatedAt, }, nil } -// GenerateUserToken creates a JWT token for the specified user with session ID +// GenerateUserToken signs a scoped JWT bound to the persisted session. The +// token carries only the subject and session ID: JWT payloads are readable by +// the holder, so profile data such as email stays behind /auth/me. func (s *Service) GenerateUserToken(userID, sessionID string) (string, error) { - user, err := s.GetUserByID(userID) - if err != nil { - return "", err - } - - claims := map[string]any{ - "username": user.Username, - "email": user.Email, - "session_id": sessionID, - } - - return auth.GenerateHS256Token(s.jwtSecret, userID, claims, SessionTTL) + return s.jwt.GenerateToken(userID, map[string]any{"session_id": sessionID}) } // ValidateToken verifies JWT token and session validity func (s *Service) ValidateToken(token string) (string, map[string]any, error) { - userID, claims, err := auth.ValidateHS256Token(s.jwtSecret, token) + userID, claims, err := s.jwt.ValidateToken(token) if err != nil { return "", nil, err } @@ -285,43 +256,3 @@ func (s *Service) ValidateToken(token string) (string, map[string]any, error) { return userID, claims, nil } - -// generateUniqueUserID creates a unique user ID with collision detection -func (s *Service) generateUniqueUserID() (string, error) { - const maxAttempts = 10 - - for i := 0; i < maxAttempts; i++ { - id := uuid.New().String() - if _, err := s.store.GetUserByID(id); errors.Is(err, sql.ErrNoRows) { - return id, nil - } else if err != nil { - return "", fmt.Errorf("%w: check generated user ID: %v", ErrStorageUnavailable, err) - } - } - - return "", fmt.Errorf("failed to generate unique user ID") -} - -// CreateUserSession creates a session for a trusted internal caller without -// re-authenticating. Public registration uses RegisterUser so account and -// initial session creation remain atomic. -func (s *Service) CreateUserSession(userID string) (string, error) { - if s.store == nil { - return "", ErrStorageDisabled - } - - sessionID := uuid.New().String() - sessionRecord := storage.SessionRecord{ - SessionID: sessionID, - UserID: userID, - CreatedAt: time.Now().UTC(), - ExpiresAt: time.Now().UTC().Add(SessionTTL), - } - - if err := s.store.CreateSession(sessionRecord); err != nil { - return "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err) - } - slog.Debug("user session created", "user_id", userID) - - return sessionID, nil -} diff --git a/internal/server/service/user_test.go b/internal/server/service/user_test.go index 0f4fd50..5a802ce 100644 --- a/internal/server/service/user_test.go +++ b/internal/server/service/user_test.go @@ -1,14 +1,21 @@ package service import ( + "errors" "testing" + "time" + "chess/internal/server/storage" + + "github.com/google/uuid" "github.com/lixenwraith/auth" ) -func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) { +var testJWTSecret = []byte("test-secret-test-secret-test-secret") + +func TestValidateTokenRequiresScopedTokenBoundToSession(t *testing.T) { svc := newPersistentTestService(t) - user, sessionID, err := svc.RegisterUser("alice", "", "Password1", false) + user, sessionID, err := svc.RegisterUser("alice", "", "Password1") if err != nil { t.Fatal(err) } @@ -17,26 +24,47 @@ func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) { if err != nil { t.Fatal(err) } - if gotUserID, _, err := svc.ValidateToken(validToken); err != nil || gotUserID != user.UserID { - t.Fatalf("valid token rejected: user=%q err=%v", gotUserID, err) + gotUserID, claims, err := svc.ValidateToken(validToken) + if err != nil || gotUserID != user.UserID || claims["session_id"] != sessionID { + t.Fatalf("valid token rejected: user=%q claims=%v err=%v", gotUserID, claims, err) } - missingSession, err := auth.GenerateHS256Token(svc.jwtSecret, user.UserID, nil, SessionTTL) + scoped, err := auth.NewJWT(testJWTSecret, + auth.WithIssuer(JWTIssuer), auth.WithAudience([]string{JWTAudience})) if err != nil { t.Fatal(err) } - if _, _, err := svc.ValidateToken(missingSession); err == nil { - t.Fatal("token without a persisted session was accepted") - } - - wrongSubject, err := auth.GenerateHS256Token(svc.jwtSecret, "other-user", map[string]any{ - "session_id": sessionID, - }, SessionTTL) + unscoped, err := auth.NewJWT(testJWTSecret) if err != nil { t.Fatal(err) } - if _, _, err := svc.ValidateToken(wrongSubject); err == nil { - t.Fatal("session was accepted for a different JWT subject") + foreignAudience, err := auth.NewJWT(testJWTSecret, + auth.WithIssuer(JWTIssuer), auth.WithAudience([]string{"other-service"})) + if err != nil { + t.Fatal(err) + } + for name, mint := range map[string]func() (string, error){ + "missing session": func() (string, error) { return scoped.GenerateToken(user.UserID, nil) }, + "wrong subject": func() (string, error) { + return scoped.GenerateToken(uuid.NewString(), map[string]any{"session_id": sessionID}) + }, + "malformed subject": func() (string, error) { + return scoped.GenerateToken("other-user", map[string]any{"session_id": sessionID}) + }, + "no issuer or audience": func() (string, error) { + return unscoped.GenerateToken(user.UserID, map[string]any{"session_id": sessionID}) + }, + "foreign audience": func() (string, error) { + return foreignAudience.GenerateToken(user.UserID, map[string]any{"session_id": sessionID}) + }, + } { + token, err := mint() + if err != nil { + t.Fatal(err) + } + if _, _, err := svc.ValidateToken(token); err == nil { + t.Errorf("%s: token accepted", name) + } } if err := svc.InvalidateSession(sessionID); err != nil { @@ -46,3 +74,75 @@ func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) { t.Fatal("invalidated session was accepted") } } + +func TestAuthenticateUserFailuresAreIndistinguishable(t *testing.T) { + svc := newPersistentTestService(t) + if _, _, err := svc.RegisterUser("alice", "alice@example.com", "Password1"); err != nil { + t.Fatal(err) + } + if _, _, err := svc.RegisterUser("ALICE", "", "Password1"); !errors.Is(err, ErrUserExists) { + t.Fatalf("duplicate registration = %v, want ErrUserExists", err) + } + + user, sessionID, err := svc.AuthenticateUser("alice@example.com", "Password1") + if err != nil || sessionID == "" || user.Username != "alice" { + t.Fatalf("login by email = %+v, %q, %v", user, sessionID, err) + } + + for name, attempt := range map[string][2]string{ + "unknown user": {"nobody", "Password1"}, + "unknown email": {"nobody@example.com", "Password1"}, + "wrong password": {"alice", "Password2"}, + } { + if _, _, err := svc.AuthenticateUser(attempt[0], attempt[1]); !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("%s: error = %v, want ErrInvalidCredentials", name, err) + } + } +} + +func TestRegistrationCapIsConfigurable(t *testing.T) { + svc := newPersistentTestService(t) + svc.SetMaxUsers(1) + if _, _, err := svc.RegisterUser("alice", "", "Password1"); err != nil { + t.Fatal(err) + } + if _, _, err := svc.RegisterUser("bob", "", "Password1"); !errors.Is(err, ErrAtCapacity) { + t.Fatalf("registration past the cap = %v, want ErrAtCapacity", err) + } + svc.SetMaxUsers(0) + if _, _, err := svc.RegisterUser("bob", "", "Password1"); err != nil { + t.Fatalf("registration without a cap: %v", err) + } +} + +func TestPasswordHashingIsBounded(t *testing.T) { + svc, err := New(nil, testJWTSecret) + if err != nil { + t.Fatal(err) + } + var releases []func() + for range MaxConcurrentKDF { + release, err := svc.acquireKDF() + if err != nil { + t.Fatal(err) + } + releases = append(releases, release) + } + started := time.Now() + if _, err := svc.hashPassword("Password1"); !errors.Is(err, ErrAuthBusy) { + t.Fatalf("saturated KDF = %v, want ErrAuthBusy", err) + } + if waited := time.Since(started); waited < kdfWaitTimeout { + t.Fatalf("gave up after %v, want %v", waited, kdfWaitTimeout) + } + releases[0]() + if _, err := svc.hashPassword("Password1"); err != nil { + t.Fatalf("hash after release: %v", err) + } +} + +func TestNewRejectsShortJWTSecret(t *testing.T) { + if _, err := New((*storage.Store)(nil), make([]byte, 31)); err == nil { + t.Fatal("31-byte JWT secret accepted") + } +} diff --git a/internal/server/storage/game.go b/internal/server/storage/game.go index 9324daf..02732a2 100644 --- a/internal/server/storage/game.go +++ b/internal/server/storage/game.go @@ -6,17 +6,29 @@ import ( "errors" "fmt" "log/slog" + "strings" "time" ) const gameSelectColumns = ` g.game_id, g.initial_fen, - g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, - g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, + g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name, + g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name, g.result, g.start_time_utc, g.end_time_utc` +// claimantName snapshots a claimant's username inside the writing +// transaction; NULL when the slot is unclaimed or the account is gone. +func claimantName(param string) string { + return "(SELECT u.username FROM users u WHERE u.user_id = " + param + ")" +} + +// MaxUserGamesPage bounds QueryGamesForUser; callers request one extra row to +// detect a following page. +const MaxUserGamesPage = 101 + // RecordNewGame asynchronously records a new game. Terminal custom-FEN games // include their result in this insert rather than relying on a second write. +// Claimed slots also record the claimant's current username. func (s *Store) RecordNewGame(record GameRecord) error { if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" { return errors.New("game ID, initial FEN, and player IDs are required") @@ -25,15 +37,16 @@ func (s *Store) RecordNewGame(record GameRecord) error { return err } - return s.enqueue("record_game", record.GameID, func(tx *sql.Tx) error { - const query = `INSERT INTO games ( + return s.enqueue("record_game", record.GameID, func(ctx context.Context, tx *sql.Tx) error { + query := `INSERT INTO games ( game_id, initial_fen, - white_player_id, white_type, white_level, white_search_time, white_claimed_by, - black_player_id, black_type, black_level, black_search_time, black_claimed_by, + white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name, + black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name, start_time_utc, result, end_time_utc - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `, + $8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15)` - _, err := tx.Exec(query, + _, err := tx.ExecContext(ctx, query, record.GameID, record.InitialFEN, record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime, nullableString(record.WhiteClaimedBy), @@ -65,11 +78,11 @@ func (s *Store) RecordMove(record MovePersistence) error { return err } - return s.enqueue("record_move", record.Move.GameID, func(tx *sql.Tx) error { + return s.enqueue("record_move", record.Move.GameID, func(ctx context.Context, tx *sql.Tx) error { const insertMove = `INSERT INTO moves ( game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc - ) VALUES (?, ?, ?, ?, ?, ?)` - if _, err := tx.Exec(insertMove, + ) VALUES ($1, $2, $3, $4, $5, $6)` + if _, err := tx.ExecContext(ctx, insertMove, record.Move.GameID, record.Move.MoveNumber, record.Move.MoveUCI, @@ -80,33 +93,35 @@ func (s *Store) RecordMove(record MovePersistence) error { return err } + if record.ClaimedBy == "" && 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 != "" { - column := "white_claimed_by" - if record.ClaimColor == "b" { - column = "black_claimed_by" - } - query := `UPDATE games SET ` + column + ` = ? - WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)` - result, err := tx.Exec(query, record.ClaimedBy, record.Move.GameID, record.ClaimedBy) - if err != nil { - return err - } - if err := requireOneGame(result, record.Move.GameID); err != nil { - return err - } + args = append(args, record.ClaimedBy) + param := fmt.Sprintf("$%d", len(args)) + set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)", + claimColumn, param, nameColumn, nameColumn, claimantName(param))) + where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param) } - if record.Result != "" { - result, err := tx.Exec( - `UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`, - record.Result, record.EndTimeUTC, record.Move.GameID, - ) - if err != nil { - return err - } - return requireOneGame(result, record.Move.GameID) + args = append(args, record.Result, record.EndTimeUTC) + set = append(set, fmt.Sprintf("result = $%d, end_time_utc = $%d", len(args)-1, len(args))) } - 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() { return errors.New("game result time is required") } - return s.enqueue("record_game_result", gameID, func(tx *sql.Tx) error { - res, err := tx.Exec( - `UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`, - result, at.UTC(), gameID, - ) - if err != nil { - return err - } - return requireOneGame(res, gameID) - }) -} - -// RecordSlotClaim persists a claim made independently from a move. -func (s *Store) RecordSlotClaim(gameID, color, userID string) error { - if gameID == "" || userID == "" { - return errors.New("game ID and claimant are required") - } - if color != "w" && color != "b" { - return fmt.Errorf("invalid claim color %q", color) - } - column := "white_claimed_by" - if color == "b" { - column = "black_claimed_by" - } - return s.enqueue("record_slot_claim", gameID, func(tx *sql.Tx) error { - query := `UPDATE games SET ` + column + ` = ? - WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)` - res, err := tx.Exec(query, userID, gameID, userID) - if err != nil { - return err - } - return requireOneGame(res, gameID) - }) -} - -// RecordPlayers keeps persisted player configuration aligned with in-memory -// configuration changes. -func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error { - if gameID == "" || white.PlayerID == "" || black.PlayerID == "" { - return errors.New("game ID and player IDs are required") - } - return s.enqueue("record_players", gameID, func(tx *sql.Tx) error { - const query = `UPDATE games SET - white_player_id = ?, white_type = ?, white_level = ?, white_search_time = ?, white_claimed_by = ?, - black_player_id = ?, black_type = ?, black_level = ?, black_search_time = ?, black_claimed_by = ? - WHERE game_id = ?` - res, err := tx.Exec(query, - white.PlayerID, white.Type, white.Level, white.SearchTime, nullableString(white.ClaimedBy), - black.PlayerID, black.Type, black.Level, black.SearchTime, nullableString(black.ClaimedBy), - gameID, + return s.enqueue("record_game_result", gameID, func(ctx context.Context, tx *sql.Tx) error { + res, err := tx.ExecContext(ctx, + `UPDATE games SET result = $2, end_time_utc = $3 WHERE game_id = $1`, + gameID, result, at.UTC(), ) if err != nil { return err @@ -189,21 +158,44 @@ type PlayerRecord struct { ClaimedBy string } +// RecordPlayers keeps persisted player configuration aligned with in-memory +// configuration changes. Claims are carried over unchanged by the service, so +// their name snapshots are left as they are. +func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error { + if gameID == "" || white.PlayerID == "" || black.PlayerID == "" { + return errors.New("game ID and player IDs are required") + } + return s.enqueue("record_players", gameID, func(ctx context.Context, tx *sql.Tx) error { + const query = `UPDATE games SET + white_player_id = $2, white_type = $3, white_level = $4, white_search_time = $5, white_claimed_by = $6, + black_player_id = $7, black_type = $8, black_level = $9, black_search_time = $10, black_claimed_by = $11 + WHERE game_id = $1` + res, err := tx.ExecContext(ctx, query, gameID, + white.PlayerID, white.Type, white.Level, white.SearchTime, nullableString(white.ClaimedBy), + black.PlayerID, black.Type, black.Level, black.SearchTime, nullableString(black.ClaimedBy), + ) + if err != nil { + return err + } + return requireOneGame(res, gameID) + }) +} + // RewindGame atomically removes undone moves and clears a previously terminal // result so replay readers never observe an ongoing line with a stale outcome. func (s *Store) RewindGame(gameID string, afterMoveNumber int) error { if gameID == "" || afterMoveNumber < 0 { return errors.New("game ID and a non-negative move number are required") } - return s.enqueue("rewind_game", gameID, func(tx *sql.Tx) error { - if _, err := tx.Exec( - `DELETE FROM moves WHERE game_id = ? AND move_number > ?`, + return s.enqueue("rewind_game", gameID, func(ctx context.Context, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, + `DELETE FROM moves WHERE game_id = $1 AND move_number > $2`, gameID, afterMoveNumber, ); err != nil { return err } - res, err := tx.Exec( - `UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = ?`, + res, err := tx.ExecContext(ctx, + `UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = $1`, gameID, ) if err != nil { @@ -213,28 +205,66 @@ func (s *Store) RewindGame(gameID string, afterMoveNumber int) error { }) } -// QueryGames retrieves games with optional filtering. A player filter matches -// both creation-time player IDs and claims made after game creation. -func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) { - if err := s.flushBeforeRead(); err != nil { - return nil, err +// DeleteAnonymousGames queues removal of games that no registered user +// claimed and whose last activity (start, last move, or end) precedes cutoff. +// Games still loaded in memory are listed in live and always kept, so a queued +// or future write can never target a deleted row. Moves cascade. +func (s *Store) DeleteAnonymousGames(cutoff time.Time, live []string) error { + if cutoff.IsZero() { + return errors.New("anonymous game cutoff is required") } - started := time.Now() - query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE 1=1` - var args []any + if live == nil { + live = []string{} + } + return s.enqueue("delete_anonymous_games", "", func(ctx context.Context, tx *sql.Tx) error { + result, err := tx.ExecContext(ctx, `DELETE FROM games g + WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL + AND g.start_time_utc < $1 + AND (g.end_time_utc IS NULL OR g.end_time_utc < $1) + AND NOT EXISTS ( + SELECT 1 FROM moves m + WHERE m.game_id = g.game_id AND m.move_time_utc >= $1 + ) + AND NOT (g.game_id = ANY($2::uuid[]))`, + cutoff.UTC(), live) + if err != nil { + return err + } + if deleted, err := result.RowsAffected(); err == nil && deleted > 0 { + slog.Info("storage anonymous games deleted", "count", deleted, "inactive_before", cutoff.UTC()) + } + return nil + }) +} +// QueryGames is the administrative game lookup. Either filter may be empty or +// "*" for all; a player filter matches creation-time player IDs and claims. +func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) { + query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE true` + var args []any if gameID != "" && gameID != "*" { - query += " AND g.game_id = ?" + if !validUUID(gameID) { + return nil, fmt.Errorf("invalid game ID %q", gameID) + } args = append(args, gameID) + query += fmt.Sprintf(" AND g.game_id = $%d", len(args)) } if playerID != "" && playerID != "*" { - query += ` AND (g.white_player_id = ? OR g.black_player_id = ? - OR g.white_claimed_by = ? OR g.black_claimed_by = ?)` - args = append(args, playerID, playerID, playerID, playerID) + if !validUUID(playerID) { + return nil, fmt.Errorf("invalid player ID %q", playerID) + } + args = append(args, playerID) + query += fmt.Sprintf(` AND $%d IN (g.white_player_id, g.black_player_id, + g.white_claimed_by, g.black_claimed_by)`, len(args)) } query += " ORDER BY g.start_time_utc DESC, g.game_id DESC" - rows, err := s.db.Query(query, args...) + if err := s.flushBeforeRead(); err != nil { + return nil, err + } + ctx, cancel := opContext() + defer cancel() + rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query games: %w", err) } @@ -251,111 +281,95 @@ func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) { if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate games: %w", err) } - slog.Debug("storage games queried", "count", len(games), "duration", time.Since(started)) return games, nil } -func (s *Store) GetGameRecord(gameID string) (*GameRecord, error) { - if err := s.flushBeforeRead(); err != nil { - return nil, err - } - return getGameRecord(s.db, gameID) -} - -type gameQueryer interface { - Query(query string, args ...any) (*sql.Rows, error) - QueryRow(query string, args ...any) *sql.Row -} - -func getGameRecord(queryer gameQueryer, gameID string) (*GameRecord, error) { - var record GameRecord - row := queryer.QueryRow(`SELECT `+gameSelectColumns+` FROM games g WHERE g.game_id = ?`, gameID) - if err := scanGame(row, &record); err != nil { - return nil, err - } - return &record, nil -} - -// GetMovesForGame returns the complete, undo-consistent replay line. -func (s *Store) GetMovesForGame(gameID string) ([]MoveRecord, error) { - if err := s.flushBeforeRead(); err != nil { - return nil, err - } - return getMovesForGame(s.db, gameID) -} - -func getMovesForGame(queryer gameQueryer, gameID string) ([]MoveRecord, error) { - const query = `SELECT move_id, game_id, move_number, move_uci, - fen_after_move, player_color, move_time_utc - FROM moves WHERE game_id = ? ORDER BY move_number ASC` - rows, err := queryer.Query(query, gameID) - if err != nil { - return nil, fmt.Errorf("query game moves: %w", err) - } - defer rows.Close() - - moves := make([]MoveRecord, 0) - for rows.Next() { - var move MoveRecord - if err := rows.Scan( - &move.MoveID, &move.GameID, &move.MoveNumber, &move.MoveUCI, - &move.FENAfterMove, &move.PlayerColor, &move.MoveTimeUTC, - ); err != nil { - return nil, fmt.Errorf("scan game move: %w", err) - } - moves = append(moves, move) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate game moves: %w", err) - } - return moves, nil -} - -// GetGameHistory uses one write barrier and one read transaction for a -// consistent game-and-moves snapshot. +// GetGameHistory returns the game row and its ordered moves after every +// previously accepted write has committed. Both reads share one REPEATABLE READ +// snapshot, so a concurrent move or rewind cannot produce a mixed history. func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error) { + if !validUUID(gameID) { + return nil, nil, sql.ErrNoRows + } if err := s.flushBeforeRead(); err != nil { return nil, nil, err } started := time.Now() - tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{ReadOnly: true}) + ctx, cancel := opContext() + defer cancel() + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) if err != nil { return nil, nil, fmt.Errorf("begin game history read: %w", err) } defer tx.Rollback() - record, err := getGameRecord(tx, gameID) - if err != nil { + var record GameRecord + row := tx.QueryRowContext(ctx, `SELECT `+gameSelectColumns+` FROM games g WHERE g.game_id = $1`, gameID) + if err := scanGame(row, &record); err != nil { return nil, nil, err } - moves, err := getMovesForGame(tx, gameID) + + rows, err := tx.QueryContext(ctx, `SELECT game_id, move_number, move_uci, + fen_after_move, player_color, move_time_utc + FROM moves WHERE game_id = $1 ORDER BY move_number`, gameID) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("query game moves: %w", err) + } + defer rows.Close() + moves := make([]MoveRecord, 0) + for rows.Next() { + var move MoveRecord + if err := rows.Scan( + &move.GameID, &move.MoveNumber, &move.MoveUCI, + &move.FENAfterMove, &move.PlayerColor, &move.MoveTimeUTC, + ); err != nil { + return nil, nil, fmt.Errorf("scan game move: %w", err) + } + move.MoveTimeUTC = move.MoveTimeUTC.UTC() + moves = append(moves, move) + } + if err := rows.Err(); err != nil { + return nil, nil, fmt.Errorf("iterate game moves: %w", err) } if err := tx.Commit(); err != nil { return nil, nil, fmt.Errorf("finish game history read: %w", err) } slog.Debug("storage game history queried", "game_id", gameID, "move_count", len(moves), "duration", time.Since(started)) - return record, moves, nil + return &record, moves, nil } +// QueryGamesForUser returns a page of games claimed by userID in either color, +// newest first. The last move is read through a LATERAL probe of the moves +// primary key, so the summary costs one index lookup per game rather than a +// move count over the whole line. func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSummaryRecord, error) { - if userID == "" || limit < 1 || limit > 101 || offset < 0 { - return nil, errors.New("user ID, limit from 1 to 101, and non-negative offset are required") + if userID == "" || limit < 1 || limit > MaxUserGamesPage || offset < 0 { + return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required", + MaxUserGamesPage) + } + if !validUUID(userID) { + return []GameSummaryRecord{}, nil } if err := s.flushBeforeRead(); err != nil { return nil, err } started := time.Now() - query := `SELECT ` + gameSelectColumns + `, - (SELECT COUNT(*) FROM moves m WHERE m.game_id = g.game_id) AS move_count + const query = `SELECT ` + gameSelectColumns + `, + COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen) FROM games g - WHERE g.white_player_id = ? OR g.black_player_id = ? - OR g.white_claimed_by = ? OR g.black_claimed_by = ? + LEFT JOIN LATERAL ( + SELECT m.move_number, m.fen_after_move FROM moves m + WHERE m.game_id = g.game_id + ORDER BY m.move_number DESC + LIMIT 1 + ) last ON true + WHERE g.white_claimed_by = $1 OR g.black_claimed_by = $1 ORDER BY g.start_time_utc DESC, g.game_id DESC - LIMIT ? OFFSET ?` - rows, err := s.db.Query(query, userID, userID, userID, userID, limit, offset) + LIMIT $2 OFFSET $3` + ctx, cancel := opContext() + defer cancel() + rows, err := s.db.QueryContext(ctx, query, userID, limit, offset) if err != nil { return nil, fmt.Errorf("query user games: %w", err) } @@ -364,7 +378,7 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma games := make([]GameSummaryRecord, 0) for rows.Next() { var summary GameSummaryRecord - if err := scanGameSummary(rows, &summary); err != nil { + if err := scanGame(rows, &summary.GameRecord, &summary.MoveCount, &summary.FinalFEN); err != nil { return nil, fmt.Errorf("scan user game: %w", err) } games = append(games, summary) @@ -386,45 +400,28 @@ type rowScanner interface { Scan(dest ...any) error } -func scanGame(scanner rowScanner, record *GameRecord) error { - var whiteClaimed, blackClaimed, result sql.NullString +// scanGame scans gameSelectColumns followed by any extra destinations. +func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { + var whiteClaimed, whiteName, blackClaimed, blackName, result sql.NullString var endTime sql.NullTime - if err := scanner.Scan( + dest := []any{ &record.GameID, &record.InitialFEN, - &record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, &whiteClaimed, - &record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed, + &record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, + &whiteClaimed, &whiteName, + &record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, + &blackClaimed, &blackName, &result, &record.StartTimeUTC, &endTime, - ); err != nil { + } + if err := scanner.Scan(append(dest, extra...)...); err != nil { return err } record.WhiteClaimedBy = whiteClaimed.String + record.WhiteName = whiteName.String record.BlackClaimedBy = blackClaimed.String + record.BlackName = blackName.String record.Result = result.String - if endTime.Valid { - ended := endTime.Time - record.EndTimeUTC = &ended - } - return nil -} - -func scanGameSummary(scanner rowScanner, summary *GameSummaryRecord) error { - var whiteClaimed, blackClaimed, result sql.NullString - var endTime sql.NullTime - if err := scanner.Scan( - &summary.GameID, &summary.InitialFEN, - &summary.WhitePlayerID, &summary.WhiteType, &summary.WhiteLevel, &summary.WhiteSearchTime, &whiteClaimed, - &summary.BlackPlayerID, &summary.BlackType, &summary.BlackLevel, &summary.BlackSearchTime, &blackClaimed, - &result, &summary.StartTimeUTC, &endTime, &summary.MoveCount, - ); err != nil { - return err - } - summary.WhiteClaimedBy = whiteClaimed.String - summary.BlackClaimedBy = blackClaimed.String - summary.Result = result.String - if endTime.Valid { - ended := endTime.Time - summary.EndTimeUTC = &ended - } + record.StartTimeUTC = record.StartTimeUTC.UTC() + record.EndTimeUTC = utcPointer(endTime) return nil } @@ -439,13 +436,6 @@ func requireOneGame(result sql.Result, gameID string) error { return nil } -func nullableString(value string) any { - if value == "" { - return nil - } - return value -} - func isValidResult(result string) bool { switch result { case "white_wins", "black_wins", "draw", "stalemate": diff --git a/internal/server/storage/pgtest/pgtest.go b/internal/server/storage/pgtest/pgtest.go new file mode 100644 index 0000000..12e8608 --- /dev/null +++ b/internal/server/storage/pgtest/pgtest.go @@ -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 +} diff --git a/internal/server/storage/schema.go b/internal/server/storage/schema.go index 9df2a36..1e9e562 100644 --- a/internal/server/storage/schema.go +++ b/internal/server/storage/schema.go @@ -2,59 +2,62 @@ package storage import "time" -// UserRecord represents a user account in the database +// UserRecord represents a user account in the database. Accounts created by +// public registration and by the CLI are identical and do not expire. type UserRecord struct { - UserID string `db:"user_id"` - Username string `db:"username"` - Email string `db:"email"` - PasswordHash string `db:"password_hash"` - AccountType string `db:"account_type"` // "permanent" or "temp" - CreatedAt time.Time `db:"created_at"` - ExpiresAt *time.Time `db:"expires_at"` // nil for permanent - LastLoginAt *time.Time `db:"last_login_at"` + UserID string + Username string + Email string // empty when the account has no email (stored as NULL) + PasswordHash string + CreatedAt time.Time + LastLoginAt *time.Time } // SessionRecord represents an active user session type SessionRecord struct { - SessionID string `db:"session_id"` - UserID string `db:"user_id"` - CreatedAt time.Time `db:"created_at"` - ExpiresAt time.Time `db:"expires_at"` + SessionID string + UserID string + CreatedAt time.Time + ExpiresAt time.Time } // GameRecord represents a row in the games table type GameRecord struct { - GameID string `db:"game_id"` - InitialFEN string `db:"initial_fen"` - WhitePlayerID string `db:"white_player_id"` - WhiteType int `db:"white_type"` - WhiteLevel int `db:"white_level"` - WhiteSearchTime int `db:"white_search_time"` - WhiteClaimedBy string `db:"white_claimed_by"` - BlackPlayerID string `db:"black_player_id"` - BlackType int `db:"black_type"` - BlackLevel int `db:"black_level"` - BlackSearchTime int `db:"black_search_time"` - BlackClaimedBy string `db:"black_claimed_by"` - Result string `db:"result"` - StartTimeUTC time.Time `db:"start_time_utc"` - EndTimeUTC *time.Time `db:"end_time_utc"` + GameID string + InitialFEN string + WhitePlayerID string + WhiteType int + WhiteLevel int + WhiteSearchTime int + WhiteClaimedBy string + WhiteName string // claimant's username when the claim was recorded + BlackPlayerID string + BlackType int + BlackLevel int + BlackSearchTime int + BlackClaimedBy string + BlackName string + Result string + StartTimeUTC time.Time + EndTimeUTC *time.Time } +// GameSummaryRecord is a game row plus its replay extent. MoveCount and +// FinalFEN come from the last move, so a picker needs no move-list read. type GameSummaryRecord struct { GameRecord - MoveCount int `db:"move_count"` + MoveCount int + FinalFEN string } // MoveRecord represents a row in the moves table type MoveRecord struct { - MoveID int64 `db:"move_id"` - GameID string `db:"game_id"` - MoveNumber int `db:"move_number"` - MoveUCI string `db:"move_uci"` - FENAfterMove string `db:"fen_after_move"` - PlayerColor string `db:"player_color"` - MoveTimeUTC time.Time `db:"move_time_utc"` + GameID string + MoveNumber int + MoveUCI string + FENAfterMove string + PlayerColor string + MoveTimeUTC time.Time } // MovePersistence groups changes caused by one accepted move so the move, @@ -67,72 +70,98 @@ type MovePersistence struct { EndTimeUTC *time.Time } -// Schema defines tables only. Indexes are applied after legacy column -// migrations so upgrading an older games table never references a missing -// column. -const Schema = ` -CREATE TABLE IF NOT EXISTS users ( - user_id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL COLLATE NOCASE, - email TEXT COLLATE NOCASE, - password_hash TEXT NOT NULL, - account_type TEXT NOT NULL DEFAULT 'temp' CHECK(account_type IN ('permanent', 'temp')), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at DATETIME, - last_login_at DATETIME +// schemaVersion is the newest schema this binary understands. Migrations are +// applied in order inside one transaction; PostgreSQL DDL is transactional, so +// a failed upgrade leaves the previous version intact. +const schemaVersion = 1 + +// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a +// released migration; append a new one. +var migrations = []string{ + // v1: initial PostgreSQL schema. + // + // Identifiers are uuid (16 bytes, validated on input). Usernames and emails + // are stored lowercase, so plain unique constraints give case-insensitive + // uniqueness without citext or a nondeterministic collation. Game-to-user + // association is by claim: every authenticated human slot records its + // claimant, and claims survive player reconfiguration. The claimant's + // username is copied into the game when the claim is written, so replays + // and PGN keep the name after a rename or account deletion. Games with no + // claim belong to anonymous players and are purged after inactivity. + ` +CREATE TABLE schema_version ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + version integer NOT NULL CHECK (version > 0), + updated_at timestamptz NOT NULL DEFAULT now() ); -CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL UNIQUE, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at DATETIME NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE +CREATE TABLE users ( + user_id uuid PRIMARY KEY, + username text NOT NULL, + email text, + password_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + last_login_at timestamptz, + CONSTRAINT users_username_key UNIQUE (username), + CONSTRAINT users_email_key UNIQUE (email), + CONSTRAINT users_username_check + CHECK (username = lower(username) AND char_length(username) BETWEEN 1 AND 64), + CONSTRAINT users_email_check + CHECK (email = lower(email) AND char_length(email) BETWEEN 3 AND 254) ); -CREATE TABLE IF NOT EXISTS games ( - game_id TEXT PRIMARY KEY, - initial_fen TEXT NOT NULL, - white_player_id TEXT NOT NULL, - white_type INTEGER NOT NULL, - white_level INTEGER NOT NULL DEFAULT 0, - white_search_time INTEGER NOT NULL DEFAULT 1000, - black_player_id TEXT NOT NULL, - black_type INTEGER NOT NULL, - black_level INTEGER NOT NULL DEFAULT 0, - black_search_time INTEGER NOT NULL DEFAULT 1000, - start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - result TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate')), - end_time_utc DATETIME, - white_claimed_by TEXT, - black_claimed_by TEXT +CREATE TABLE sessions ( + session_id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + CONSTRAINT sessions_user_id_key UNIQUE (user_id) ); -CREATE TABLE IF NOT EXISTS moves ( - move_id INTEGER PRIMARY KEY AUTOINCREMENT, - game_id TEXT NOT NULL, - move_number INTEGER NOT NULL, - move_uci TEXT NOT NULL, - fen_after_move TEXT NOT NULL, - player_color TEXT NOT NULL CHECK(player_color IN ('w', 'b')), - move_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE, - UNIQUE(game_id, move_number) +CREATE TABLE games ( + game_id uuid PRIMARY KEY, + initial_fen text NOT NULL CHECK (initial_fen <> ''), + white_player_id uuid NOT NULL, + white_type smallint NOT NULL CHECK (white_type IN (1, 2)), + white_level smallint NOT NULL DEFAULT 0 CHECK (white_level BETWEEN 0 AND 20), + white_search_time integer NOT NULL DEFAULT 0 CHECK (white_search_time >= 0), + white_claimed_by uuid, + white_name text CHECK (char_length(white_name) BETWEEN 1 AND 64), + black_player_id uuid NOT NULL, + black_type smallint NOT NULL CHECK (black_type IN (1, 2)), + black_level smallint NOT NULL DEFAULT 0 CHECK (black_level BETWEEN 0 AND 20), + black_search_time integer NOT NULL DEFAULT 0 CHECK (black_search_time >= 0), + black_claimed_by uuid, + black_name text CHECK (char_length(black_name) BETWEEN 1 AND 64), + start_time_utc timestamptz NOT NULL DEFAULT now(), + result text CHECK (result IN ('white_wins', 'black_wins', 'draw', 'stalemate')), + end_time_utc timestamptz, + CONSTRAINT games_result_end_time_check CHECK ((result IS NULL) = (end_time_utc IS NULL)) ); -` -const Indexes = ` -CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique - ON users(email) WHERE email IS NOT NULL AND email != ''; -CREATE INDEX IF NOT EXISTS idx_users_temp_created_at - ON users(created_at) WHERE account_type = 'temp'; -CREATE INDEX IF NOT EXISTS idx_users_temp_expires_at - ON users(expires_at) WHERE account_type = 'temp' AND expires_at IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); -CREATE INDEX IF NOT EXISTS idx_games_white_player ON games(white_player_id); -CREATE INDEX IF NOT EXISTS idx_games_black_player ON games(black_player_id); -CREATE INDEX IF NOT EXISTS idx_games_white_claimed ON games(white_claimed_by) +-- Moves are numbered 1..n without gaps; the primary key serves ordered replay +-- reads and the last-move probe used by game listings. +CREATE TABLE moves ( + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + move_number integer NOT NULL CHECK (move_number > 0), + move_uci text NOT NULL CHECK (move_uci ~ '^[a-h][1-8][a-h][1-8][qrbn]?$'), + fen_after_move text NOT NULL CHECK (fen_after_move <> ''), + player_color text NOT NULL CHECK (player_color IN ('w', 'b')), + move_time_utc timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (game_id, move_number) +); + +CREATE INDEX sessions_expires_at_idx ON sessions (expires_at); +-- Candidates for the anonymous-game purge: a game that started after the +-- cutoff cannot have been idle since it. +CREATE INDEX games_anonymous_start_idx ON games (start_time_utc) + WHERE white_claimed_by IS NULL AND black_claimed_by IS NULL; +CREATE INDEX games_white_claimed_idx ON games (white_claimed_by, start_time_utc DESC, game_id DESC) WHERE white_claimed_by IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_games_black_claimed ON games(black_claimed_by) +CREATE INDEX games_black_claimed_idx ON games (black_claimed_by, start_time_utc DESC, game_id DESC) WHERE black_claimed_by IS NOT NULL; -` +`, +} + +// ownedTables lists every table created by migrations, children first. +var ownedTables = []string{"moves", "games", "sessions", "users", "schema_version"} diff --git a/internal/server/storage/session.go b/internal/server/storage/session.go index 170790b..e81c694 100644 --- a/internal/server/storage/session.go +++ b/internal/server/storage/session.go @@ -1,20 +1,30 @@ package storage import ( + "database/sql" "fmt" "log/slog" "time" ) -// CreateSession creates or replaces the session for a user (single session per user) +// CreateSession creates or replaces the user's single session and records the +// login time in the same statement, so a login is one round trip. func (s *Store) CreateSession(record SessionRecord) error { - const query = `INSERT INTO sessions (session_id, user_id, created_at, expires_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - session_id = excluded.session_id, - created_at = excluded.created_at, - expires_at = excluded.expires_at` - if _, err := s.db.Exec(query, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil { + ctx, cancel := opContext() + defer cancel() + const query = `WITH session AS ( + INSERT INTO sessions (session_id, user_id, created_at, expires_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id) DO UPDATE SET + session_id = excluded.session_id, + created_at = excluded.created_at, + expires_at = excluded.expires_at + RETURNING user_id + ) + UPDATE users SET last_login_at = $3 WHERE user_id = (SELECT user_id FROM session)` + if _, err := s.db.ExecContext(ctx, query, + record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt, + ); err != nil { return fmt.Errorf("failed to create session: %w", err) } slog.Debug("storage session created", "user_id", record.UserID, "expires_at", record.ExpiresAt) @@ -23,56 +33,44 @@ func (s *Store) CreateSession(record SessionRecord) error { // GetSession retrieves a session by ID func (s *Store) GetSession(sessionID string) (*SessionRecord, error) { + if !validUUID(sessionID) { + return nil, sql.ErrNoRows + } + ctx, cancel := opContext() + defer cancel() var session SessionRecord - query := `SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE session_id = ?` - - err := s.db.QueryRow(query, sessionID).Scan( - &session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt, - ) + err := s.db.QueryRowContext(ctx, + `SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE session_id = $1`, + sessionID, + ).Scan(&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt) if err != nil { return nil, err } + session.CreatedAt = session.CreatedAt.UTC() + session.ExpiresAt = session.ExpiresAt.UTC() return &session, nil } -// GetSessionByUserID retrieves the active session for a user -func (s *Store) GetSessionByUserID(userID string) (*SessionRecord, error) { - var session SessionRecord - query := `SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE user_id = ?` - - err := s.db.QueryRow(query, userID).Scan( - &session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt, - ) - if err != nil { - return nil, err - } - return &session, nil -} - -// DeleteSession removes a session +// DeleteSession removes a session (logout). Deleting an absent session is not +// an error. func (s *Store) DeleteSession(sessionID string) error { - query := `DELETE FROM sessions WHERE session_id = ?` - _, err := s.db.Exec(query, sessionID) - if err == nil { - slog.Debug("storage session deleted") + if !validUUID(sessionID) { + return nil } - return err -} - -// DeleteSessionByUserID removes all sessions for a user -func (s *Store) DeleteSessionByUserID(userID string) error { - query := `DELETE FROM sessions WHERE user_id = ?` - _, err := s.db.Exec(query, userID) - if err == nil { - slog.Debug("storage user sessions deleted", "user_id", userID) + ctx, cancel := opContext() + defer cancel() + if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE session_id = $1`, sessionID); err != nil { + return err } - return err + slog.Debug("storage session deleted") + return nil } // DeleteExpiredSessions removes expired sessions func (s *Store) DeleteExpiredSessions() (int64, error) { - query := `DELETE FROM sessions WHERE expires_at < ?` - result, err := s.db.Exec(query, time.Now().UTC()) + ctx, cancel := opContext() + defer cancel() + result, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < $1`, time.Now().UTC()) if err != nil { return 0, err } @@ -83,29 +81,20 @@ func (s *Store) DeleteExpiredSessions() (int64, error) { return deleted, err } -// IsSessionValid checks if a session exists and is not expired -func (s *Store) IsSessionValid(sessionID string) (bool, error) { - var valid bool - const query = `SELECT EXISTS( - SELECT 1 FROM sessions WHERE session_id = ? AND expires_at > ? - )` - err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&valid) - if err != nil { - return false, err - } - return valid, nil -} - // IsSessionValidForUser verifies both expiry and the binding between a JWT // subject and its persisted session. Checking only the session ID would allow // a malformed server-issued token to authenticate as the wrong subject. func (s *Store) IsSessionValidForUser(sessionID, userID string) (bool, error) { + if !validUUID(sessionID) || !validUUID(userID) { + return false, nil + } + ctx, cancel := opContext() + defer cancel() var valid bool - const query = `SELECT EXISTS( + err := s.db.QueryRowContext(ctx, `SELECT EXISTS( SELECT 1 FROM sessions - WHERE session_id = ? AND user_id = ? AND expires_at > ? - )` - err := s.db.QueryRow(query, sessionID, userID, time.Now().UTC()).Scan(&valid) + WHERE session_id = $1 AND user_id = $2 AND expires_at > $3 + )`, sessionID, userID, time.Now().UTC()).Scan(&valid) if err != nil { return false, err } diff --git a/internal/server/storage/storage.go b/internal/server/storage/storage.go index 06ad915..1891096 100644 --- a/internal/server/storage/storage.go +++ b/internal/server/storage/storage.go @@ -3,25 +3,43 @@ package storage import ( "context" "database/sql" + "database/sql/driver" "errors" "fmt" + "io" "log/slog" - "os" + "net" "strings" "sync" "sync/atomic" "time" - _ "github.com/mattn/go-sqlite3" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/stdlib" ) const ( writeQueueCapacity = 1000 flushTimeout = 5 * time.Second - schemaVersion = 2 -) + queryTimeout = 5 * time.Second + writeTimeout = 10 * time.Second + migrationTimeout = time.Minute -var memoryStoreCounter atomic.Uint64 + // A transient failure (PostgreSQL restart, dropped connection) is retried + // before storage degrades. Backoff doubles from writeRetryBase, bounding + // the stall of the ordered writer to roughly three seconds. + writeAttempts = 6 + writeRetryBase = 100 * time.Millisecond + + maxOpenConns = 10 + maxIdleConns = 5 + + // schemaLockKey serializes migrations between the server and the CLI. + // Advisory locks are scoped to the current database. + schemaLockKey int64 = 0x6368657373 // "chess" +) var ( ErrStorageDegraded = errors.New("storage is degraded") @@ -32,14 +50,14 @@ var ( type writeRequest struct { operation string gameID string - run func(*sql.Tx) error + run func(context.Context, *sql.Tx) error barrier chan error } -// Store handles SQLite database operations with async writes for games and sync writes for auth +// Store handles PostgreSQL operations with ordered async writes for gameplay +// and synchronous writes for accounts and sessions. type Store struct { db *sql.DB - path string writeChan chan writeRequest healthStatus atomic.Bool writeFailed atomic.Bool @@ -52,68 +70,55 @@ type Store struct { closeErr error } -// NewStore creates a new storage instance with async writer -func NewStore(dataSourceName string, devMode bool) (*Store, error) { - dsn := sqliteDSN(dataSourceName) - db, err := sql.Open("sqlite3", dsn) +// NewStore connects to PostgreSQL and starts the async writer. dsn is a +// libpq-style keyword/value string or postgres:// URL; unset fields fall back +// to the standard PG* environment variables and ~/.pgpass. The schema is +// resolved through search_path, so the role's default or a search_path DSN +// parameter selects it. +func NewStore(dsn string) (*Store, error) { + config, err := pgx.ParseConfig(dsn) if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) + // Parse errors may echo the DSN, which can contain a password. + return nil, errors.New("invalid database connection string") + } + if _, ok := config.RuntimeParams["application_name"]; !ok { + config.RuntimeParams["application_name"] = "chess-server" } - if err := db.Ping(); err != nil { + db := stdlib.OpenDB(*config) + db.SetMaxOpenConns(maxOpenConns) + db.SetMaxIdleConns(maxIdleConns) + db.SetConnMaxIdleTime(5 * time.Minute) + db.SetConnMaxLifetime(30 * time.Minute) + + ctx, cancel := context.WithTimeout(context.Background(), queryTimeout) + defer cancel() + if err := db.PingContext(ctx); err != nil { db.Close() return nil, fmt.Errorf("failed to connect to database: %w", err) } - // SQLite benefits from a small pool. WAL and busy_timeout are configured in - // the DSN for every connection, unlike connection-local PRAGMA calls. - db.SetMaxOpenConns(8) - db.SetMaxIdleConns(4) - - ctx, cancel := context.WithCancel(context.Background()) - + writerCtx, writerCancel := context.WithCancel(context.Background()) s := &Store{ db: db, - path: dataSourceName, writeChan: make(chan writeRequest, writeQueueCapacity), - ctx: ctx, - cancel: cancel, + ctx: writerCtx, + cancel: writerCancel, } - - // Initialize health as true s.healthStatus.Store(true) - // Start async writer s.wg.Add(1) go s.writerLoop() slog.Debug("storage opened", - "path", dataSourceName, - "dev_mode", devMode, + "host", config.Host, + "database", config.Database, + "user", config.User, "write_queue_capacity", writeQueueCapacity, - "max_open_connections", 8, + "max_open_connections", maxOpenConns, ) - return s, nil } -func sqliteDSN(dataSourceName string) string { - if dataSourceName == ":memory:" { - // Each Store needs a private shared-cache database: shared cache keeps - // that Store's pooled connections on one database, while the unique name - // prevents independent in-memory stores from leaking into each other. - dataSourceName = fmt.Sprintf( - "file:chess-memory-%d?mode=memory&cache=shared", - memoryStoreCounter.Add(1), - ) - } - separator := "?" - if strings.Contains(dataSourceName, "?") { - separator = "&" - } - return dataSourceName + separator + - "_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL" -} - // IsHealthy returns true if the storage is operational func (s *Store) IsHealthy() bool { return s.healthStatus.Load() @@ -176,49 +181,63 @@ func (s *Store) handleWrite(req writeRequest) { } } -// executeWrite runs a transactional write operation +// executeWrite runs one logical mutation as one transaction. A transient error +// raised before COMMIT is retried: the transaction was rolled back, so a retry +// cannot duplicate it, and the single writer preserves ordering while it waits. +// A COMMIT error has an unknown outcome and is never retried. Any unrecovered +// failure degrades storage permanently, because skipping one write would leave +// later writes applied to an incomplete history. func (s *Store) executeWrite(req writeRequest) error { started := time.Now() - tx, err := s.db.Begin() - if err != nil { - s.writeFailed.Store(true) - s.healthStatus.Store(false) - slog.Error("storage degraded: failed to begin transaction", - "operation", req.operation, "game_id", req.gameID, "error", err) - return err + var err error + for attempt := 1; ; attempt++ { + var committing bool + committing, err = s.runWriteTx(req.run) + if err == nil { + slog.Debug("storage write committed", + "operation", req.operation, + "game_id", req.gameID, + "attempts", attempt, + "duration", time.Since(started), + "queue_depth", len(s.writeChan), + ) + return nil + } + if committing || attempt >= writeAttempts || !isTransient(err) { + break + } + backoff := writeRetryBase << (attempt - 1) + slog.Warn("storage write failed transiently; retrying", + "operation", req.operation, "game_id", req.gameID, + "attempt", attempt, "backoff", backoff, "error", err) + time.Sleep(backoff) } - if err := req.run(tx); err != nil { - rollbackErr := tx.Rollback() - s.writeFailed.Store(true) - s.healthStatus.Store(false) - slog.Error("storage degraded: write operation failed", - "operation", req.operation, - "game_id", req.gameID, - "error", err, - "rollback_error", rollbackErr, - ) - return err - } - - if err := tx.Commit(); err != nil { - s.writeFailed.Store(true) - s.healthStatus.Store(false) - slog.Error("storage degraded: failed to commit", - "operation", req.operation, "game_id", req.gameID, "error", err) - return err - } - - slog.Debug("storage write committed", - "operation", req.operation, - "game_id", req.gameID, - "duration", time.Since(started), - "queue_depth", len(s.writeChan), - ) - return nil + s.writeFailed.Store(true) + s.healthStatus.Store(false) + slog.Error("storage degraded: write operation failed", + "operation", req.operation, "game_id", req.gameID, "error", err) + return err } -func (s *Store) enqueue(operation, gameID string, fn func(*sql.Tx) error) error { +func (s *Store) runWriteTx(run func(context.Context, *sql.Tx) error) (committing bool, err error) { + ctx, cancel := context.WithTimeout(context.Background(), writeTimeout) + defer cancel() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, fmt.Errorf("begin transaction: %w", err) + } + if err := run(ctx, tx); err != nil { + _ = tx.Rollback() + return false, err + } + if err := tx.Commit(); err != nil { + return true, fmt.Errorf("commit transaction: %w", err) + } + return true, nil +} + +func (s *Store) enqueue(operation, gameID string, fn func(context.Context, *sql.Tx) error) error { s.enqueueMu.RLock() defer s.enqueueMu.RUnlock() @@ -285,7 +304,7 @@ func (s *Store) flushBeforeRead() error { return s.Flush(ctx) } -// Close gracefully closes the database connection +// Close drains accepted writes and closes the connection pool. func (s *Store) Close() error { s.closeOnce.Do(func() { s.enqueueMu.Lock() @@ -301,125 +320,189 @@ func (s *Store) Close() error { return s.closeErr } -// InitDB creates the database schema +// InitDB applies pending migrations. It holds a transaction-scoped advisory +// lock so concurrent migrators serialize, and issues no DDL when the schema is +// current: a runtime role limited to DML can start the server once an owner +// role has migrated the schema. func (s *Store) InitDB() error { started := time.Now() - tx, err := s.db.Begin() + ctx, cancel := context.WithTimeout(context.Background(), migrationTimeout) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() - var currentVersion int - if err := tx.QueryRow("PRAGMA user_version").Scan(¤tVersion); err != nil { - return fmt.Errorf("failed to read schema version: %w", err) + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, schemaLockKey); err != nil { + return fmt.Errorf("failed to lock schema: %w", err) } - if currentVersion > schemaVersion { - return fmt.Errorf( - "database schema version %d is newer than supported version %d", - currentVersion, - schemaVersion, - ) + current, err := readSchemaVersion(ctx, tx) + if err != nil { + return err + } + if current > schemaVersion { + return fmt.Errorf("database schema version %d is newer than supported version %d", + current, schemaVersion) + } + if current == schemaVersion { + slog.Debug("storage schema current", "version", current) + return tx.Commit() } - if _, err := tx.Exec(Schema); err != nil { - return fmt.Errorf("failed to create schema: %w", err) - } - - columns := []struct { - name string - definition string - }{ - {"result", "TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate'))"}, - {"end_time_utc", "DATETIME"}, - {"white_claimed_by", "TEXT"}, - {"black_claimed_by", "TEXT"}, - } - for _, column := range columns { - if err := ensureColumn(tx, "games", column.name, column.definition); err != nil { - return err + for version := current + 1; version <= schemaVersion; version++ { + if _, err := tx.ExecContext(ctx, migrations[version-1]); err != nil { + return fmt.Errorf("failed to apply schema version %d: %w", version, err) } } - - // These indexes duplicate UNIQUE constraints or are superseded by targeted - // partial/composite indexes. Drop them during upgrades as well as omitting - // them from new databases. - for _, name := range []string{ - "idx_users_username", - "idx_users_email", - "idx_users_account_type", - "idx_users_expires_at", - "idx_sessions_user_id", - "idx_moves_game_id", - "idx_games_finished_end_time", - } { - if _, err := tx.Exec("DROP INDEX IF EXISTS " + name); err != nil { - return fmt.Errorf("failed to remove redundant index %s: %w", name, err) - } - } - - if _, err := tx.Exec(Indexes); err != nil { - return fmt.Errorf("failed to create indexes: %w", err) - } - if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", schemaVersion)); err != nil { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO schema_version (version) VALUES ($1) + ON CONFLICT (singleton) DO UPDATE SET version = excluded.version, updated_at = now()`, + schemaVersion, + ); err != nil { return fmt.Errorf("failed to record schema version: %w", err) } - if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit schema: %w", err) } - slog.Debug("storage schema ready", "version", schemaVersion, "duration", time.Since(started)) + slog.Info("storage schema migrated", "from", current, "to", schemaVersion, + "duration", time.Since(started)) return nil } -func ensureColumn(tx *sql.Tx, table, column, definition string) error { - rows, err := tx.Query("PRAGMA table_info(" + table + ")") +// SchemaVersion reports the version recorded in the database; 0 means no +// schema has been created in the current search_path. +func (s *Store) SchemaVersion() (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), queryTimeout) + defer cancel() + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { - return fmt.Errorf("failed to inspect %s schema: %w", table, err) + return 0, err } + defer tx.Rollback() + return readSchemaVersion(ctx, tx) +} - found := false - for rows.Next() { - var cid int - var name, columnType string - var notNull, primaryKey int - var defaultValue any - if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { - rows.Close() - return fmt.Errorf("failed to inspect %s column: %w", table, err) - } - if name == column { - found = true +func readSchemaVersion(ctx context.Context, tx *sql.Tx) (int, error) { + var exists bool + if err := tx.QueryRowContext(ctx, + `SELECT to_regclass('schema_version') IS NOT NULL`, + ).Scan(&exists); err != nil { + return 0, fmt.Errorf("failed to inspect schema: %w", err) + } + if !exists { + return 0, nil + } + var version int + err := tx.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&version) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != 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 { - return fmt.Errorf("failed to close %s schema rows: %w", table, err) + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return false } - if err := rows.Err(); err != nil { - return fmt.Errorf("failed to inspect %s schema: %w", table, err) + var connectErr *pgconn.ConnectError + var netErr net.Error + return errors.As(err, &connectErr) || + errors.As(err, &netErr) || + errors.Is(err, driver.ErrBadConn) || + errors.Is(err, io.EOF) || + errors.Is(err, io.ErrUnexpectedEOF) || + pgconn.SafeToRetry(err) +} + +// uniqueViolation returns the violated constraint name for SQLSTATE 23505. +func uniqueViolation(err error) (string, bool) { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return pgErr.ConstraintName, true } - 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 } - - 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 + return value } -// DeleteDB removes the database file -func (s *Store) DeleteDB() error { - // Close connection first - if err := s.Close(); err != nil { - return fmt.Errorf("failed to close database: %w", err) +func utcPointer(value sql.NullTime) *time.Time { + if !value.Valid { + return nil } + t := value.Time.UTC() + return &t +} - // ☣ DESTRUCTIVE: Removes database file - if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to delete database file: %w", err) +// ErrSchemaMismatch reports a database whose schema is absent or at a version +// other than the one this binary supports. +var ErrSchemaMismatch = errors.New("database schema version mismatch") + +// CheckSchema verifies that the schema is current without changing it. +func (s *Store) CheckSchema() error { + version, err := s.SchemaVersion() + if err != nil { + return err + } + switch { + case version == 0: + return fmt.Errorf("%w: schema not initialized (run 'chess-server db init')", ErrSchemaMismatch) + case version != schemaVersion: + return fmt.Errorf("%w: database has version %d, binary supports %d", + ErrSchemaMismatch, version, schemaVersion) } - return nil } diff --git a/internal/server/storage/storage_test.go b/internal/server/storage/storage_test.go index c031f3a..dcaec47 100644 --- a/internal/server/storage/storage_test.go +++ b/internal/server/storage/storage_test.go @@ -4,235 +4,290 @@ import ( "context" "database/sql" "errors" - "path/filepath" + "fmt" + "io" "strings" + "sync" "testing" "time" - _ "github.com/mattn/go-sqlite3" + "chess/internal/server/storage/pgtest" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" ) -func TestInitDBMigratesLegacySchemaAndRemovesRedundantIndexes(t *testing.T) { - path := filepath.Join(t.TempDir(), "legacy.db") - db, err := sql.Open("sqlite3", path) - if err != nil { - t.Fatal(err) - } - const legacy = ` - CREATE TABLE users ( - user_id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL COLLATE NOCASE, - email TEXT COLLATE NOCASE, - password_hash TEXT NOT NULL, - account_type TEXT NOT NULL DEFAULT 'temp', - created_at DATETIME NOT NULL, - expires_at DATETIME, - last_login_at DATETIME - ); - CREATE TABLE sessions ( - session_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL UNIQUE, - created_at DATETIME NOT NULL, - expires_at DATETIME NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE - ); - CREATE TABLE games ( - game_id TEXT PRIMARY KEY, - initial_fen TEXT NOT NULL, - white_player_id TEXT NOT NULL, - white_type INTEGER NOT NULL, - white_level INTEGER NOT NULL DEFAULT 0, - white_search_time INTEGER NOT NULL DEFAULT 1000, - black_player_id TEXT NOT NULL, - black_type INTEGER NOT NULL, - black_level INTEGER NOT NULL DEFAULT 0, - black_search_time INTEGER NOT NULL DEFAULT 1000, - start_time_utc DATETIME NOT NULL - ); - CREATE TABLE moves ( - move_id INTEGER PRIMARY KEY AUTOINCREMENT, - game_id TEXT NOT NULL, - move_number INTEGER NOT NULL, - move_uci TEXT NOT NULL, - fen_after_move TEXT NOT NULL, - player_color TEXT NOT NULL, - move_time_utc DATETIME NOT NULL, - FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE, - UNIQUE(game_id, move_number) - ); - CREATE INDEX idx_users_username ON users(username); - CREATE INDEX idx_users_email ON users(email); - CREATE INDEX idx_users_account_type ON users(account_type); - CREATE INDEX idx_users_expires_at ON users(expires_at); - CREATE INDEX idx_sessions_user_id ON sessions(user_id); - CREATE INDEX idx_moves_game_id ON moves(game_id);` - if _, err := db.Exec(legacy); err != nil { - t.Fatalf("create legacy schema: %v", err) - } - if err := db.Close(); err != nil { - t.Fatal(err) - } - - store, err := NewStore(path, false) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = store.Close() }) +func TestInitDBIsIdempotentAndRejectsNewerSchema(t *testing.T) { + dsn := pgtest.DSN(t) + store := openStore(t, dsn) if err := store.InitDB(); err != nil { - t.Fatalf("migrate schema: %v", err) + t.Fatalf("second InitDB: %v", err) + } + if version, err := store.SchemaVersion(); err != nil || version != schemaVersion { + t.Fatalf("schema version = %d, %v; want %d", version, err, schemaVersion) } - columns := tableColumns(t, store.db, "games") - for _, column := range []string{"result", "end_time_utc", "white_claimed_by", "black_claimed_by"} { - if !columns[column] { - t.Errorf("migration did not add games.%s", column) - } - } - - indexes := schemaIndexes(t, store.db) - for _, obsolete := range []string{ - "idx_users_username", "idx_users_email", "idx_users_account_type", - "idx_users_expires_at", "idx_sessions_user_id", "idx_moves_game_id", - "idx_games_finished_end_time", - } { - if indexes[obsolete] { - t.Errorf("redundant index %s remains", obsolete) - } - } - for _, required := range []string{ - "idx_users_email_unique", "idx_users_temp_created_at", "idx_users_temp_expires_at", - "idx_sessions_expires_at", "idx_games_white_player", "idx_games_black_player", - "idx_games_white_claimed", "idx_games_black_claimed", - } { - if !indexes[required] { - t.Errorf("required index %s is missing", required) - } - } - - var version, foreignKeys int - if err := store.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + if _, err := store.db.Exec(`UPDATE schema_version SET version = $1`, schemaVersion+1); err != nil { t.Fatal(err) } - if version != 2 { - t.Errorf("schema version = %d, want 2", version) + if err := store.InitDB(); err == nil || !strings.Contains(err.Error(), "newer than supported") { + t.Fatalf("InitDB error = %v, want newer-version rejection", err) } - if err := store.db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys); err != nil { + if version, _ := store.SchemaVersion(); version != schemaVersion+1 { + t.Fatalf("newer schema version was overwritten: %d", version) + } +} + +func TestInitDBIssuesNoDDLWhenCurrent(t *testing.T) { + dsn := pgtest.DSN(t) + openStore(t, dsn) // migrates + + // A read-only session rejects any DDL or write, standing in for a runtime + // role that holds only DML privileges on an owner-migrated schema. + readOnly, err := NewStore(pgtest.WithParam(dsn, "default_transaction_read_only", "on")) + if err != nil { t.Fatal(err) } - if foreignKeys != 1 { - t.Errorf("foreign_keys = %d, want 1", foreignKeys) + t.Cleanup(func() { _ = readOnly.Close() }) + if err := readOnly.InitDB(); err != nil { + t.Fatalf("InitDB on a current schema must not write: %v", err) + } +} + +func TestDropSchemaRemovesOwnedTables(t *testing.T) { + store := openStore(t, pgtest.DSN(t)) + if err := store.DropSchema(); err != nil { + t.Fatal(err) + } + if version, err := store.SchemaVersion(); err != nil || version != 0 { + t.Fatalf("schema version after drop = %d, %v; want 0", version, err) + } + if err := store.InitDB(); err != nil { + t.Fatalf("recreate after drop: %v", err) + } +} + +func TestSchemaConstraintsRejectInconsistentRows(t *testing.T) { + store := openStore(t, pgtest.DSN(t)) + gameID := uuid.NewString() + for name, query := range map[string]string{ + "result without end time": `INSERT INTO games (game_id, initial_fen, white_player_id, white_type, + black_player_id, black_type, result) VALUES ($1, 'fen', $1, 1, $1, 1, 'draw')`, + "end time without result": `INSERT INTO games (game_id, initial_fen, white_player_id, white_type, + black_player_id, black_type, end_time_utc) VALUES ($1, 'fen', $1, 1, $1, 1, now())`, + "invalid player type": `INSERT INTO games (game_id, initial_fen, white_player_id, white_type, + black_player_id, black_type) VALUES ($1, 'fen', $1, 3, $1, 1)`, + "uppercase username": `INSERT INTO users (user_id, username, password_hash) + VALUES ($1, 'Alice', 'hash')`, + "empty player name": `INSERT INTO games (game_id, initial_fen, white_player_id, white_type, + black_player_id, black_type, white_name) VALUES ($1, 'fen', $1, 1, $1, 1, '')`, + } { + if _, err := store.db.Exec(query, gameID); err == nil { + t.Errorf("%s: insert succeeded", name) + } + } + if _, err := store.db.Exec(`INSERT INTO games (game_id, initial_fen, white_player_id, white_type, + black_player_id, black_type) VALUES ($1, 'fen', $1, 1, $1, 1)`, gameID); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`INSERT INTO moves (game_id, move_number, move_uci, fen_after_move, + player_color) VALUES ($1, 1, 'e2e4; DROP', 'fen', 'w')`, gameID); err == nil { + t.Error("malformed UCI move accepted") } } func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) { - store := newTestStore(t) + store := openStore(t, pgtest.DSN(t)) + gameID, anonymous, black, user := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() started := time.Date(2026, 9, 7, 1, 2, 3, 0, time.UTC) ended := started.Add(5 * time.Minute) if err := store.RecordNewGame(GameRecord{ - GameID: "game-1", InitialFEN: "initial", - WhitePlayerID: "anonymous-white", WhiteType: 1, - BlackPlayerID: "black", BlackType: 1, + GameID: gameID, InitialFEN: "initial", + WhitePlayerID: anonymous, WhiteType: 1, + BlackPlayerID: black, BlackType: 1, StartTimeUTC: started, }); err != nil { t.Fatal(err) } if err := store.RecordMove(MovePersistence{ Move: MoveRecord{ - GameID: "game-1", MoveNumber: 1, MoveUCI: "e2e4", + GameID: gameID, MoveNumber: 1, MoveUCI: "e2e4", FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended, }, - ClaimColor: "w", ClaimedBy: "user-1", + ClaimColor: "w", ClaimedBy: user, Result: "white_wins", EndTimeUTC: &ended, }); err != nil { t.Fatal(err) } // GetGameHistory must observe both queued writes without sleeps or polling. - gameRecord, moves, err := store.GetGameHistory("game-1") + record, moves, err := store.GetGameHistory(gameID) if err != nil { t.Fatal(err) } - if gameRecord.WhiteClaimedBy != "user-1" || gameRecord.Result != "white_wins" || gameRecord.EndTimeUTC == nil { - t.Fatalf("durable game mutation incomplete: %+v", gameRecord) + if record.WhiteClaimedBy != user || record.Result != "white_wins" || + record.EndTimeUTC == nil || !record.EndTimeUTC.Equal(ended) || !record.StartTimeUTC.Equal(started) { + t.Fatalf("durable game mutation incomplete: %+v", record) + } + if record.StartTimeUTC.Location() != time.UTC { + t.Fatalf("start time location = %v, want UTC", record.StartTimeUTC.Location()) } if len(moves) != 1 || moves[0].MoveNumber != 1 || moves[0].FENAfterMove != "after-e2e4" { t.Fatalf("moves = %+v", moves) } - owned, err := store.QueryGamesForUser("user-1", 10, 0) + owned, err := store.QueryGamesForUser(user, 10, 0) if err != nil { t.Fatal(err) } - if len(owned) != 1 || owned[0].MoveCount != 1 { + if len(owned) != 1 || owned[0].MoveCount != 1 || owned[0].FinalFEN != "after-e2e4" { t.Fatalf("claimed game lookup = %+v", owned) } - if err := store.RewindGame("game-1", 0); err != nil { + if err := store.RewindGame(gameID, 0); err != nil { t.Fatal(err) } - gameRecord, moves, err = store.GetGameHistory("game-1") + record, moves, err = store.GetGameHistory(gameID) if err != nil { t.Fatal(err) } - if gameRecord.Result != "" || gameRecord.EndTimeUTC != nil || len(moves) != 0 { - t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", gameRecord, moves) + if record.Result != "" || record.EndTimeUTC != nil || len(moves) != 0 { + t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", record, moves) + } + owned, err = store.QueryGamesForUser(user, 10, 0) + if err != nil { + t.Fatal(err) + } + if len(owned) != 1 || owned[0].MoveCount != 0 || owned[0].FinalFEN != "initial" { + t.Fatalf("rewound summary = %+v", owned) + } + + if _, _, err := store.GetGameHistory("not-a-uuid"); !IsGameNotFound(err) { + t.Fatalf("malformed ID error = %v, want not found", err) + } + if _, _, err := store.GetGameHistory(uuid.NewString()); !IsGameNotFound(err) { + t.Fatalf("unknown ID error = %v, want not found", err) } } -func TestConnectionSettingsApplyAcrossPool(t *testing.T) { - store := newTestStore(t) - ctx := context.Background() - connections := make([]*sql.Conn, 0, 8) - defer func() { - for _, connection := range connections { - _ = connection.Close() +func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) { + store := openStore(t, pgtest.DSN(t)) + user, other := uuid.NewString(), uuid.NewString() + base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + var want []string + for i := range 5 { + record := GameRecord{ + GameID: uuid.NewString(), InitialFEN: "initial", + WhitePlayerID: uuid.NewString(), WhiteType: 1, + BlackPlayerID: uuid.NewString(), BlackType: 1, + StartTimeUTC: base.Add(time.Duration(i) * time.Minute), } - }() + switch i { + case 0: + record.WhiteClaimedBy = user + case 1: + record.BlackClaimedBy = user + case 2: + record.WhiteClaimedBy, record.BlackClaimedBy = user, user // self-play + case 3: + record.WhiteClaimedBy = other + case 4: + record.BlackClaimedBy, record.WhiteClaimedBy = user, other + } + if err := store.RecordNewGame(record); err != nil { + t.Fatal(err) + } + if i != 3 { + want = append([]string{record.GameID}, want...) + } + } - // Keep each connection checked out so the pool must create eight distinct - // SQLite connections, then verify connection-local PRAGMAs on every one. - for range 8 { - connection, err := store.db.Conn(ctx) + var got []string + for offset := 0; ; offset += 2 { + page, err := store.QueryGamesForUser(user, 2, offset) if err != nil { t.Fatal(err) } - connections = append(connections, connection) + for _, game := range page { + got = append(got, game.GameID) + } + if len(page) < 2 { + break + } } - for i, connection := range connections { - var foreignKeys, busyTimeout, synchronous int - var journalMode string - if err := connection.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil { - t.Fatal(err) - } - if err := connection.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout); err != nil { - t.Fatal(err) - } - if err := connection.QueryRowContext(ctx, "PRAGMA synchronous").Scan(&synchronous); err != nil { - t.Fatal(err) - } - if err := connection.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode); err != nil { - t.Fatal(err) - } - if foreignKeys != 1 || busyTimeout != 5000 || synchronous != 1 || !strings.EqualFold(journalMode, "wal") { - t.Errorf( - "connection %d settings: foreign_keys=%d busy_timeout=%d synchronous=%d journal_mode=%s", - i, foreignKeys, busyTimeout, synchronous, journalMode, - ) - } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("user games = %v, want %v", got, want) + } + if games, err := store.QueryGamesForUser("not-a-uuid", 10, 0); err != nil || len(games) != 0 { + t.Fatalf("malformed user ID = %v, %v; want empty", games, err) + } +} + +func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) { + store := openStore(t, pgtest.DSN(t)) + userID := uuid.NewString() + + tests := []struct { + name string + query string + args []any + want []string + }{ + { + name: "user games use both claim indexes", + query: `SELECT game_id FROM games g + WHERE g.white_claimed_by = $1 OR g.black_claimed_by = $1 + ORDER BY g.start_time_utc DESC, g.game_id DESC LIMIT 50`, + args: []any{userID}, + want: []string{"games_white_claimed_idx", "games_black_claimed_idx"}, + }, + { + name: "last move probe uses the moves primary key", + query: `SELECT move_number FROM moves WHERE game_id = $1 + ORDER BY move_number DESC LIMIT 1`, + args: []any{userID}, + want: []string{"moves_pkey"}, + }, + { + name: "anonymous purge candidates", + query: `SELECT game_id FROM games g + WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL + AND g.start_time_utc < $1`, + args: []any{time.Now()}, + want: []string{"games_anonymous_start_idx"}, + }, + { + name: "session expiry cleanup", + query: `SELECT session_id FROM sessions WHERE expires_at < $1`, + args: []any{time.Now()}, + want: []string{"sessions_expires_at_idx"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan := explain(t, store.db, test.query, test.args...) + for _, index := range test.want { + if !strings.Contains(plan, index) { + t.Errorf("query plan does not use %s:\n%s", index, plan) + } + } + }) } } func TestAcceptedWriteCommitsAfterQueueAdmissionFailureMarksHealthDegraded(t *testing.T) { - store := newTestStore(t) + store := openStore(t, pgtest.DSN(t)) store.healthStatus.Store(false) // Queue saturation rejects new work but accepted work must drain. + userID := uuid.NewString() done := make(chan error, 1) store.handleWrite(writeRequest{ operation: "accepted_before_saturation", - run: func(tx *sql.Tx) error { - _, err := tx.Exec(`INSERT INTO users - (user_id, username, password_hash, account_type, created_at) - VALUES ('user-1', 'alice', 'hash', 'permanent', ?)`, time.Now().UTC()) + run: func(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `INSERT INTO users + (user_id, username, password_hash, created_at) + VALUES ($1, 'alice', 'hash', now())`, userID) return err }, barrier: done, @@ -240,28 +295,31 @@ func TestAcceptedWriteCommitsAfterQueueAdmissionFailureMarksHealthDegraded(t *te if err := <-done; err != nil { t.Fatalf("accepted write was discarded: %v", err) } - if _, err := store.GetUserByID("user-1"); err != nil { + if _, err := store.GetUserByID(userID); err != nil { t.Fatalf("accepted write was not committed: %v", err) } } func TestWritesAfterTransactionFailureAreSkippedExplicitly(t *testing.T) { - store := newTestStore(t) + store := openStore(t, pgtest.DSN(t)) failed := make(chan error, 1) store.handleWrite(writeRequest{ operation: "forced_failure", - run: func(*sql.Tx) error { return errors.New("forced failure") }, + run: func(context.Context, *sql.Tx) error { return errors.New("forced failure") }, barrier: failed, }) if err := <-failed; err == nil { t.Fatal("forced transaction failure was not reported") } + if store.IsHealthy() { + t.Fatal("permanent failure did not degrade storage") + } ran := false skipped := make(chan error, 1) store.handleWrite(writeRequest{ operation: "after_failure", - run: func(*sql.Tx) error { + run: func(context.Context, *sql.Tx) error { ran = true return nil }, @@ -275,178 +333,422 @@ func TestWritesAfterTransactionFailureAreSkippedExplicitly(t *testing.T) { } } -func TestNewerSchemaVersionIsRejected(t *testing.T) { - store := newTestStore(t) - if _, err := store.db.Exec("PRAGMA user_version = 3"); err != nil { +func TestWriteSurvivesTerminatedConnection(t *testing.T) { + dsn := pgtest.DSN(t) + store := openStore(t, dsn) + store.db.SetMaxOpenConns(1) + store.db.SetMaxIdleConns(1) + + var pid int + if err := store.db.QueryRow(`SELECT pg_backend_pid()`).Scan(&pid); err != nil { t.Fatal(err) } - if err := store.InitDB(); err == nil || !strings.Contains(err.Error(), "newer than supported") { - t.Fatalf("InitDB error = %v, want newer-version rejection", err) - } - var version int - if err := store.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + admin, err := sql.Open("pgx", dsn) + if err != nil { t.Fatal(err) } - if version != 3 { - t.Fatalf("newer schema version was overwritten: %d", version) + defer admin.Close() + var terminated bool + if err := admin.QueryRow(`SELECT pg_terminate_backend($1)`, pid).Scan(&terminated); err != nil || !terminated { + t.Skipf("cannot terminate own backend (terminated=%v): %v", terminated, err) + } + + // The pooled connection is now dead. The ordered writer must reconnect and + // commit instead of degrading storage. + gameID := uuid.NewString() + if err := store.RecordNewGame(GameRecord{ + GameID: gameID, InitialFEN: "initial", + WhitePlayerID: uuid.NewString(), WhiteType: 1, + BlackPlayerID: uuid.NewString(), BlackType: 1, + StartTimeUTC: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + if _, _, err := store.GetGameHistory(gameID); err != nil { + t.Fatalf("write after connection loss: %v", err) + } + if !store.IsHealthy() { + t.Fatal("transient connection loss degraded storage") } } -func TestQueryPlansUseOnlyPurposeBuiltOrConstraintIndexes(t *testing.T) { - store := newTestStore(t) - now := time.Now().UTC() - - tests := []struct { - name string - query string - args []any - want []string +func TestTransientErrorClassification(t *testing.T) { + for _, test := range []struct { + err error + want bool }{ - { - name: "email partial uniqueness", - query: `SELECT user_id FROM users - WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''`, - args: []any{"alice@example.com"}, want: []string{"idx_users_email_unique"}, - }, - { - name: "temporary expiry cleanup", - query: `SELECT user_id FROM users - WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?`, - args: []any{now}, want: []string{"idx_users_temp_expires_at"}, - }, - { - name: "oldest temporary account", - query: `SELECT user_id FROM users - WHERE account_type = 'temp' ORDER BY created_at ASC LIMIT 1`, - want: []string{"idx_users_temp_created_at"}, - }, - { - name: "ordered moves use composite unique constraint", - query: `SELECT move_uci FROM moves - WHERE game_id = ? ORDER BY move_number ASC`, - args: []any{"game-1"}, want: []string{"sqlite_autoindex_moves_1"}, - }, - { - name: "all user association branches", - query: `SELECT game_id, - (SELECT COUNT(*) FROM moves m WHERE m.game_id = games.game_id) - FROM games WHERE white_player_id = ? OR black_player_id = ? - OR white_claimed_by = ? OR black_claimed_by = ? - ORDER BY start_time_utc DESC, game_id DESC LIMIT ? OFFSET ?`, - args: []any{"user-1", "user-1", "user-1", "user-1", 50, 0}, - want: []string{ - "idx_games_white_player", "idx_games_black_player", - "idx_games_white_claimed", "idx_games_black_claimed", - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - plan := explainQueryPlan(t, store.db, test.query, test.args...) - for _, index := range test.want { - if !strings.Contains(plan, index) { - t.Errorf("query plan does not use %s:\n%s", index, plan) - } - } - }) + {&pgconn.PgError{Code: "57P01"}, true}, + {&pgconn.PgError{Code: "08006"}, true}, + {&pgconn.PgError{Code: "40001"}, true}, + {fmt.Errorf("wrapped: %w", io.ErrUnexpectedEOF), true}, + {&pgconn.PgError{Code: "23505"}, false}, + {&pgconn.PgError{Code: "42P01"}, false}, + {context.DeadlineExceeded, false}, + {errors.New("forced"), false}, + } { + if got := isTransient(test.err); got != test.want { + t.Errorf("isTransient(%v) = %v, want %v", test.err, got, test.want) + } } } func TestForeignKeyCascadeAppliesToSessions(t *testing.T) { - store := newTestStore(t) + store := openStore(t, pgtest.DSN(t)) now := time.Now().UTC() + userID, sessionID := uuid.NewString(), uuid.NewString() if err := store.CreateUser(UserRecord{ - UserID: "user-1", Username: "user1", PasswordHash: "hash", - AccountType: "permanent", CreatedAt: now, + UserID: userID, Username: "user1", PasswordHash: "hash", CreatedAt: now, }); err != nil { t.Fatal(err) } if err := store.CreateSession(SessionRecord{ - SessionID: "session-1", UserID: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Hour), + SessionID: sessionID, UserID: userID, CreatedAt: now, ExpiresAt: now.Add(time.Hour), }); err != nil { t.Fatal(err) } - if err := store.DeleteUser("user-1"); err != nil { + user, err := store.GetUserByID(userID) + if err != nil { t.Fatal(err) } - if _, err := store.GetSession("session-1"); !errors.Is(err, sql.ErrNoRows) { + if user.LastLoginAt == nil || !user.LastLoginAt.Equal(now.Truncate(time.Microsecond)) { + t.Fatalf("session creation did not record login time: %v", user.LastLoginAt) + } + if err := store.DeleteUser(userID); err != nil { + t.Fatal(err) + } + if _, err := store.GetSession(sessionID); !errors.Is(err, sql.ErrNoRows) { t.Fatalf("session survived user cascade: %v", err) } + if err := store.DeleteUser(userID); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("deleting a missing user = %v, want sql.ErrNoRows", err) + } } func TestLimitedUserCreationIsAtomicWithInitialSession(t *testing.T) { - store := newTestStore(t) + store := openStore(t, pgtest.DSN(t)) now := time.Now().UTC() - limits := UserLimits{MaxUsers: 1, PermanentSlots: 1} + limits := UserLimits{MaxUsers: 2} first := UserRecord{ - UserID: "user-1", Username: "alice", PasswordHash: "hash", - AccountType: "temp", CreatedAt: now, ExpiresAt: timePointer(now.Add(time.Hour)), + UserID: uuid.NewString(), Username: "alice", Email: "Alice@Example.com", + PasswordHash: "hash", CreatedAt: now, } firstSession := SessionRecord{ - SessionID: "session-1", UserID: first.UserID, + SessionID: uuid.NewString(), UserID: first.UserID, CreatedAt: now, ExpiresAt: now.Add(time.Hour), } if err := store.CreateUserWithinLimits(first, &firstSession, limits); err != nil { t.Fatal(err) } - - duplicate := first - duplicate.UserID = "user-duplicate" - if err := store.CreateUserWithinLimits(duplicate, nil, limits); !errors.Is(err, ErrUserAlreadyExists) { - t.Fatalf("duplicate error = %v, want ErrUserAlreadyExists", err) - } - if _, err := store.GetUserByID(first.UserID); err != nil { - t.Fatalf("duplicate registration evicted existing user: %v", err) - } - - second := UserRecord{ - UserID: "user-2", Username: "bob", PasswordHash: "hash", - AccountType: "temp", CreatedAt: now.Add(time.Minute), - ExpiresAt: timePointer(now.Add(2 * time.Hour)), - } - secondSession := SessionRecord{ - SessionID: "session-2", UserID: second.UserID, - CreatedAt: now.Add(time.Minute), ExpiresAt: now.Add(2 * time.Hour), - } - if err := store.CreateUserWithinLimits(second, &secondSession, limits); err != nil { - t.Fatal(err) - } - if _, err := store.GetUserByID(first.UserID); !errors.Is(err, sql.ErrNoRows) { - t.Fatalf("oldest temporary user was not replaced: %v", err) - } - if _, err := store.GetSession(firstSession.SessionID); !errors.Is(err, sql.ErrNoRows) { - t.Fatalf("evicted user's session survived cascade: %v", err) - } - if _, err := store.GetSession(secondSession.SessionID); err != nil { + if _, err := store.GetSession(firstSession.SessionID); err != nil { t.Fatalf("initial session not committed with user: %v", err) } - third := UserRecord{ - UserID: "user-3", Username: "charlie", PasswordHash: "hash", - AccountType: "temp", CreatedAt: now.Add(2 * time.Minute), - } + // A session failure rolls back the account. + conflicting := UserRecord{UserID: uuid.NewString(), Username: "carol", PasswordHash: "hash", CreatedAt: now} conflictingSession := SessionRecord{ - SessionID: secondSession.SessionID, UserID: third.UserID, + SessionID: firstSession.SessionID, UserID: conflicting.UserID, CreatedAt: now, ExpiresAt: now.Add(time.Hour), } - wideLimits := UserLimits{MaxUsers: 10, PermanentSlots: 2} - if err := store.CreateUserWithinLimits(third, &conflictingSession, wideLimits); err == nil { + if err := store.CreateUserWithinLimits(conflicting, &conflictingSession, limits); err == nil { t.Fatal("expected duplicate session failure") } - if _, err := store.GetUserByID(third.UserID); !errors.Is(err, sql.ErrNoRows) { + if _, err := store.GetUserByID(conflicting.UserID); !errors.Is(err, sql.ErrNoRows) { t.Fatalf("session failure did not roll back user: %v", err) } + + second := UserRecord{UserID: uuid.NewString(), Username: "bob", PasswordHash: "hash", CreatedAt: now} + if err := store.CreateUserWithinLimits(second, nil, limits); err != nil { + t.Fatal(err) + } + + // At capacity, a duplicate is still reported as a duplicate, a new name + // is refused, and nobody is removed to make room. + for _, duplicate := range []UserRecord{ + {Username: "ALICE", Email: "other@example.com"}, + {Username: "dave", Email: "alice@EXAMPLE.com"}, + } { + duplicate.UserID, duplicate.PasswordHash, duplicate.CreatedAt = uuid.NewString(), "hash", now + if err := store.CreateUserWithinLimits(duplicate, nil, limits); !errors.Is(err, ErrUserAlreadyExists) { + t.Fatalf("duplicate %+v error = %v, want ErrUserAlreadyExists", duplicate, err) + } + } + third := UserRecord{UserID: uuid.NewString(), Username: "erin", PasswordHash: "hash", CreatedAt: now} + if err := store.CreateUserWithinLimits(third, nil, limits); !errors.Is(err, ErrUserCapacity) { + t.Fatalf("registration at capacity = %v, want ErrUserCapacity", err) + } + for _, existing := range []string{first.UserID, second.UserID} { + if _, err := store.GetUserByID(existing); err != nil { + t.Fatalf("existing user %s removed at capacity: %v", existing, err) + } + } + + // Administrative creation and a zero limit are not capped. + if err := store.CreateUser(third); err != nil { + t.Fatalf("CLI creation beyond the public limit: %v", err) + } + unlimited := UserRecord{UserID: uuid.NewString(), Username: "frank", PasswordHash: "hash", CreatedAt: now} + if err := store.CreateUserWithinLimits(unlimited, nil, UserLimits{}); err != nil { + t.Fatalf("zero limit: %v", err) + } +} + +func TestConcurrentRegistrationsRespectCapacity(t *testing.T) { + store := openStore(t, pgtest.DSN(t)) + limits := UserLimits{MaxUsers: 3} + var wg sync.WaitGroup + errs := make(chan error, 12) + for i := range 12 { + wg.Add(1) + go func() { + defer wg.Done() + errs <- store.CreateUserWithinLimits(UserRecord{ + UserID: uuid.NewString(), Username: fmt.Sprintf("user%d", i), + PasswordHash: "hash", CreatedAt: time.Now().UTC(), + }, nil, limits) + }() + } + wg.Wait() + close(errs) + created := 0 + for err := range errs { + switch { + case err == nil: + created++ + case !errors.Is(err, ErrUserCapacity): + t.Errorf("registration failed: %v", err) + } + } + users, err := store.GetAllUsers() + if err != nil { + t.Fatal(err) + } + if created != limits.MaxUsers || len(users) != limits.MaxUsers { + t.Fatalf("created %d, stored %d; want %d", created, len(users), limits.MaxUsers) + } +} + +func TestUserLookupsAndUpdates(t *testing.T) { + store := openStore(t, pgtest.DSN(t)) + now := time.Now().UTC() + alice := UserRecord{ + UserID: uuid.NewString(), Username: "Alice", Email: "Alice@Example.com", + PasswordHash: "hash", CreatedAt: now, + } + bob := UserRecord{UserID: uuid.NewString(), Username: "bob", PasswordHash: "hash", CreatedAt: now} + for _, user := range []UserRecord{alice, bob} { + if err := store.CreateUser(user); err != nil { + t.Fatal(err) + } + } + + byName, err := store.GetUserByUsername("ALICE") + if err != nil || byName.UserID != alice.UserID || byName.Username != "alice" || byName.Email != "alice@example.com" { + t.Fatalf("username lookup = %+v, %v", byName, err) + } + if byEmail, err := store.GetUserByEmail("ALICE@example.COM"); err != nil || byEmail.UserID != alice.UserID { + t.Fatalf("email lookup = %+v, %v", byEmail, err) + } + if byID, err := store.GetUserByID(bob.UserID); err != nil || byID.Email != "" { + t.Fatalf("user without email = %+v, %v", byID, err) + } + if _, err := store.GetUserByEmail(""); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("empty email lookup = %v, want sql.ErrNoRows", err) + } + if _, err := store.GetUserByID("not-a-uuid"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("malformed ID lookup = %v, want sql.ErrNoRows", err) + } + + if err := store.UpdateUserUsername(bob.UserID, "ALICE"); !errors.Is(err, ErrUserAlreadyExists) { + t.Fatalf("conflicting rename = %v, want ErrUserAlreadyExists", err) + } + if err := store.UpdateUserEmail(bob.UserID, "alice@example.com"); !errors.Is(err, ErrUserAlreadyExists) { + t.Fatalf("conflicting email = %v, want ErrUserAlreadyExists", err) + } + if err := store.UpdateUserEmail(alice.UserID, ""); err != nil { + t.Fatal(err) + } + if err := store.UpdateUserEmail(bob.UserID, "alice@example.com"); err != nil { + t.Fatalf("released email not reusable: %v", err) + } + if err := store.UpdateUserPassword(uuid.NewString(), "hash"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("update of missing user = %v, want sql.ErrNoRows", err) + } +} + +func TestExpiredSessionsAreDeleted(t *testing.T) { + store := openStore(t, pgtest.DSN(t)) + now := time.Now().UTC() + userID := uuid.NewString() + if err := store.CreateUser(UserRecord{ + UserID: userID, Username: "user", PasswordHash: "hash", CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + oldSession := uuid.NewString() + if err := store.CreateSession(SessionRecord{ + SessionID: oldSession, UserID: userID, + CreatedAt: now.Add(-2 * time.Hour), ExpiresAt: now.Add(-time.Hour), + }); err != nil { + t.Fatal(err) + } + if valid, err := store.IsSessionValidForUser(oldSession, userID); err != nil || valid { + t.Fatalf("expired session valid = %v, %v", valid, err) + } + if deleted, err := store.DeleteExpiredSessions(); err != nil || deleted != 1 { + t.Fatalf("expired sessions deleted = %d, %v; want 1", deleted, err) + } + if _, err := store.GetUserByID(userID); err != nil { + t.Fatalf("session cleanup removed the account: %v", err) + } + if valid, err := store.IsSessionValidForUser("not-a-uuid", userID); err != nil || valid { + t.Fatalf("malformed session valid = %v, %v", valid, err) + } +} + +func TestAnonymousGamesArePurgedAfterInactivity(t *testing.T) { + store := openStore(t, pgtest.DSN(t)) + now := time.Now().UTC() + cutoff := now.Add(-24 * time.Hour) + old := now.Add(-48 * time.Hour) + userID := uuid.NewString() + + newGame := func(started time.Time, claimedBy string, result string, ended *time.Time) string { + t.Helper() + record := GameRecord{ + GameID: uuid.NewString(), InitialFEN: "initial", + WhitePlayerID: uuid.NewString(), WhiteType: 1, WhiteClaimedBy: claimedBy, + BlackPlayerID: uuid.NewString(), BlackType: 2, BlackLevel: 5, BlackSearchTime: 100, + StartTimeUTC: started, Result: result, EndTimeUTC: ended, + } + if err := store.RecordNewGame(record); err != nil { + t.Fatal(err) + } + return record.GameID + } + move := func(gameID string, number int, at time.Time) { + t.Helper() + color := "w" + if number%2 == 0 { + color = "b" + } + if err := store.RecordMove(MovePersistence{Move: MoveRecord{ + GameID: gameID, MoveNumber: number, MoveUCI: "e2e4", + FENAfterMove: "after", PlayerColor: color, MoveTimeUTC: at, + }}); err != nil { + t.Fatal(err) + } + } + + idle := newGame(old, "", "", nil) + move(idle, 1, old.Add(time.Minute)) + recentMove := newGame(old, "", "", nil) + move(recentMove, 1, now.Add(-time.Hour)) + recentEnd := newGame(old, "", "stalemate", timePointer(now.Add(-time.Hour))) + recentStart := newGame(now.Add(-time.Hour), "", "", nil) + live := newGame(old, "", "", nil) + claimed := newGame(old, userID, "", nil) + idleFinished := newGame(old, "", "white_wins", timePointer(old.Add(time.Hour))) + + if err := store.DeleteAnonymousGames(cutoff, []string{live}); err != nil { + t.Fatal(err) + } + for gameID, wantKept := range map[string]bool{ + idle: false, idleFinished: false, + recentMove: true, recentEnd: true, recentStart: true, live: true, claimed: true, + } { + _, moves, err := store.GetGameHistory(gameID) + switch { + case wantKept && err != nil: + t.Errorf("game %s deleted: %v", gameID, err) + case !wantKept && !IsGameNotFound(err): + t.Errorf("game %s kept (err=%v, moves=%d)", gameID, err, len(moves)) + } + } + var orphanMoves int + if err := store.db.QueryRow(`SELECT count(*) FROM moves WHERE game_id = $1`, idle).Scan(&orphanMoves); err != nil || orphanMoves != 0 { + t.Fatalf("moves of deleted game = %d, %v", orphanMoves, err) + } + if err := store.DeleteAnonymousGames(cutoff, nil); err != nil { + t.Fatalf("nil live set: %v", err) + } + if _, _, err := store.GetGameHistory(live); !IsGameNotFound(err) { + t.Fatalf("idle game kept after leaving memory: %v", err) + } +} + +func TestClaimsSnapshotPlayerNames(t *testing.T) { + store := openStore(t, pgtest.DSN(t)) + now := time.Now().UTC() + alice, bob := uuid.NewString(), uuid.NewString() + for id, name := range map[string]string{alice: "alice", bob: "bob"} { + if err := store.CreateUser(UserRecord{UserID: id, Username: name, PasswordHash: "hash", CreatedAt: now}); err != nil { + t.Fatal(err) + } + } + + gameID := uuid.NewString() + if err := store.RecordNewGame(GameRecord{ + GameID: gameID, InitialFEN: "initial", + WhitePlayerID: alice, WhiteType: 1, WhiteClaimedBy: alice, + BlackPlayerID: uuid.NewString(), BlackType: 1, + StartTimeUTC: now, + }); err != nil { + t.Fatal(err) + } + for number, claim := range map[int]string{1: "", 2: bob} { + color := map[int]string{1: "w", 2: "b"}[number] + persistence := MovePersistence{Move: MoveRecord{ + GameID: gameID, MoveNumber: number, MoveUCI: "e2e4", + FENAfterMove: "after", PlayerColor: color, MoveTimeUTC: now, + }} + if claim != "" { + persistence.ClaimColor, persistence.ClaimedBy = color, claim + } + if err := store.RecordMove(persistence); err != nil { + t.Fatal(err) + } + } + record, _, err := store.GetGameHistory(gameID) + if err != nil || record.WhiteName != "alice" || record.BlackName != "bob" { + t.Fatalf("names = %q/%q, %v", record.WhiteName, record.BlackName, err) + } + + // The snapshot survives a rename and account deletion. + if err := store.UpdateUserUsername(bob, "robert"); err != nil { + t.Fatal(err) + } + if err := store.DeleteUser(alice); err != nil { + t.Fatal(err) + } + record, _, err = store.GetGameHistory(gameID) + if err != nil || record.WhiteName != "alice" || record.BlackName != "bob" || record.WhiteClaimedBy != alice { + t.Fatalf("names after rename/delete = %q/%q claim=%q, %v", + record.WhiteName, record.BlackName, record.WhiteClaimedBy, err) + } + + // A claimant without an account (removed before the write) has no name. + orphan := uuid.NewString() + if err := store.RecordNewGame(GameRecord{ + GameID: orphan, InitialFEN: "initial", + WhitePlayerID: uuid.NewString(), WhiteType: 1, WhiteClaimedBy: uuid.NewString(), + BlackPlayerID: uuid.NewString(), BlackType: 2, + StartTimeUTC: now, + }); err != nil { + t.Fatal(err) + } + if record, _, err := store.GetGameHistory(orphan); err != nil || record.WhiteName != "" { + t.Fatalf("orphan claim name = %q, %v", record.WhiteName, err) + } } func timePointer(value time.Time) *time.Time { return &value } -func newTestStore(t *testing.T) *Store { +func openStore(t *testing.T, dsn string) *Store { t.Helper() - store, err := NewStore(filepath.Join(t.TempDir(), "chess.db"), false) + store, err := NewStore(dsn) if err != nil { t.Fatal(err) } @@ -457,69 +759,36 @@ func newTestStore(t *testing.T) *Store { return store } -func tableColumns(t *testing.T, db *sql.DB, table string) map[string]bool { +// explain returns the plan with sequential scans disabled, so the assertion is +// about index usability rather than the planner's choice for tiny tables. +func explain(t *testing.T, db *sql.DB, query string, args ...any) string { t.Helper() - rows, err := db.Query("PRAGMA table_info(" + table + ")") + ctx := context.Background() + conn, err := db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := conn.ExecContext(ctx, "SET enable_seqscan = off"); err != nil { + t.Fatal(err) + } + defer conn.ExecContext(ctx, "RESET enable_seqscan") + + rows, err := conn.QueryContext(ctx, "EXPLAIN "+query, args...) if err != nil { t.Fatal(err) } defer rows.Close() - columns := make(map[string]bool) + var lines []string for rows.Next() { - var cid, notNull, primaryKey int - var name, columnType string - var defaultValue any - if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + var line string + if err := rows.Scan(&line); err != nil { t.Fatal(err) } - columns[name] = true + lines = append(lines, line) } if err := rows.Err(); err != nil { t.Fatal(err) } - return columns -} - -func schemaIndexes(t *testing.T, db *sql.DB) map[string]bool { - t.Helper() - rows, err := db.Query(`SELECT name FROM sqlite_schema WHERE type = 'index'`) - if err != nil { - t.Fatal(err) - } - defer rows.Close() - indexes := make(map[string]bool) - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - t.Fatal(err) - } - indexes[name] = true - } - if err := rows.Err(); err != nil { - t.Fatal(err) - } - return indexes -} - -func explainQueryPlan(t *testing.T, db *sql.DB, query string, args ...any) string { - t.Helper() - rows, err := db.Query("EXPLAIN QUERY PLAN "+query, args...) - if err != nil { - t.Fatal(err) - } - defer rows.Close() - - var details []string - for rows.Next() { - var id, parent, notUsed int - var detail string - if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { - t.Fatal(err) - } - details = append(details, detail) - } - if err := rows.Err(); err != nil { - t.Fatal(err) - } - return strings.Join(details, "\n") + return strings.Join(lines, "\n") } diff --git a/internal/server/storage/user.go b/internal/server/storage/user.go index 3842b4d..f0c88b3 100644 --- a/internal/server/storage/user.go +++ b/internal/server/storage/user.go @@ -5,76 +5,22 @@ import ( "errors" "fmt" "log/slog" - "time" ) var ( ErrUserAlreadyExists = errors.New("username or email already exists") ErrUserCapacity = errors.New("user capacity reached") - ErrPermanentCapacity = errors.New("permanent user capacity reached") ) -// UserLimits defines registration constraints +// userCreateLockKey serializes account creation across processes (server and +// CLI) so the capacity check cannot be raced past. +const userCreateLockKey int64 = 0x6368657375 // "chesu" + +const userSelectColumns = `user_id, username, email, password_hash, created_at, last_login_at` + +// UserLimits defines public-registration constraints. type UserLimits struct { - MaxUsers int - PermanentSlots int -} - -// DefaultUserLimits returns default POC limits -func DefaultUserLimits() UserLimits { - return UserLimits{ - MaxUsers: 100, - PermanentSlots: 10, - } -} - -// GetUserCounts returns current user counts by type -func (s *Store) GetUserCounts() (total, permanent, temp int, err error) { - query := `SELECT - COUNT(*) as total, - COUNT(CASE WHEN account_type = 'permanent' THEN 1 END) as permanent, - COUNT(CASE WHEN account_type = 'temp' THEN 1 END) as temp - FROM users` - - err = s.db.QueryRow(query).Scan(&total, &permanent, &temp) - return -} - -// GetOldestTempUser returns the oldest temporary user for replacement -func (s *Store) GetOldestTempUser() (*UserRecord, error) { - var user UserRecord - var email sql.NullString - query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at - FROM users - WHERE account_type = 'temp' - ORDER BY created_at ASC - LIMIT 1` - - err := s.db.QueryRow(query).Scan( - &user.UserID, &user.Username, &email, - &user.PasswordHash, &user.AccountType, &user.CreatedAt, - &user.ExpiresAt, &user.LastLoginAt, - ) - if err != nil { - return nil, err - } - user.Email = email.String - return &user, nil -} - -// DeleteExpiredTempUsers removes temporary users past their expiry -func (s *Store) DeleteExpiredTempUsers() (int64, error) { - query := `DELETE FROM users - WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?` - result, err := s.db.Exec(query, time.Now().UTC()) - if err != nil { - return 0, err - } - deleted, err := result.RowsAffected() - if err == nil && deleted > 0 { - slog.Debug("storage expired temporary users deleted", "count", deleted) - } - return deleted, err + MaxUsers int // total accounts at which registration closes } // CreateUser creates an administratively managed user without applying the @@ -83,10 +29,9 @@ func (s *Store) CreateUser(record UserRecord) error { return s.createUser(record, nil, nil) } -// CreateUserWithinLimits atomically applies registration limits, evicts the -// oldest temporary account when required, creates the user, and optionally -// creates its initial session. No account is evicted on a duplicate request, -// and a session failure rolls back the user and eviction together. +// CreateUserWithinLimits atomically applies the registration limit, creates +// the user, and optionally creates its initial session. A session failure +// rolls back the account. func (s *Store) CreateUserWithinLimits( record UserRecord, session *SessionRecord, @@ -96,69 +41,55 @@ func (s *Store) CreateUserWithinLimits( } func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *UserLimits) error { - tx, err := s.db.Begin() + if session != nil && session.UserID != record.UserID { + return errors.New("initial session user does not match new user") + } + ctx, cancel := opContext() + defer cancel() + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() - // Check uniqueness within transaction - exists, err := s.userExists(tx, record.Username, record.Email) - if err != nil { - return err + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, userCreateLockKey); err != nil { + return fmt.Errorf("lock user creation: %w", err) + } + + // Report a duplicate as such even when registration is full. The advisory + // lock orders this check with concurrent creations; the unique constraints + // still catch a race with renames. + var exists bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM users WHERE username = lower($1) OR email = lower($2) + )`, record.Username, nullableString(record.Email)).Scan(&exists); err != nil { + return fmt.Errorf("check user uniqueness: %w", err) } if exists { return ErrUserAlreadyExists } - if limits != nil { - var total, permanent int - if err := tx.QueryRow(`SELECT COUNT(*), - COUNT(CASE WHEN account_type = 'permanent' THEN 1 END) - FROM users`).Scan(&total, &permanent); err != nil { + if limits != nil && limits.MaxUsers > 0 { + var total int + if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&total); err != nil { return fmt.Errorf("count users: %w", err) } - if record.AccountType == "permanent" && permanent >= limits.PermanentSlots { - return ErrPermanentCapacity - } if total >= limits.MaxUsers { - result, err := tx.Exec(`DELETE FROM users WHERE user_id = ( - SELECT user_id FROM users - WHERE account_type = 'temp' - ORDER BY created_at ASC - LIMIT 1 - )`) - if err != nil { - return fmt.Errorf("evict oldest temporary user: %w", err) - } - deleted, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("inspect temporary user eviction: %w", err) - } - if deleted != 1 { - return ErrUserCapacity - } + return ErrUserCapacity } } - // Insert user - query := `INSERT INTO users ( - user_id, username, email, password_hash, account_type, created_at, expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)` - - _, err = tx.Exec(query, - record.UserID, record.Username, record.Email, - record.PasswordHash, record.AccountType, record.CreatedAt, record.ExpiresAt, - ) - if err != nil { - return err + if _, err := tx.ExecContext(ctx, `INSERT INTO users ( + user_id, username, email, password_hash, created_at + ) VALUES ($1, lower($2), lower($3), $4, $5)`, + record.UserID, record.Username, nullableString(record.Email), + record.PasswordHash, record.CreatedAt, + ); err != nil { + return mapUserConflict(err) } if session != nil { - if session.UserID != record.UserID { - return errors.New("initial session user does not match new user") - } - if _, err := tx.Exec( - `INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`, + if _, err := tx.ExecContext(ctx, + `INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES ($1, $2, $3, $4)`, session.SessionID, session.UserID, session.CreatedAt, session.ExpiresAt, ); err != nil { return fmt.Errorf("create initial session: %w", err) @@ -168,78 +99,74 @@ func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *Us if err := tx.Commit(); err != nil { return err } - slog.Debug("storage user created", - "user_id", record.UserID, - "account_type", record.AccountType, - "initial_session", session != nil, - ) + slog.Debug("storage user created", "user_id", record.UserID, "initial_session", session != nil) return nil } -// DeleteUserByID removes a user by ID (synchronous, for replacement logic) -func (s *Store) DeleteUserByID(userID string) error { - query := `DELETE FROM users WHERE user_id = ?` - _, err := s.db.Exec(query, userID) - if err == nil { - slog.Debug("storage user deleted", "user_id", userID) +// mapUserConflict converts a username/email uniqueness violation, including one +// lost to a concurrent writer, into ErrUserAlreadyExists. +func mapUserConflict(err error) error { + if constraint, ok := uniqueViolation(err); ok && + (constraint == "users_username_key" || constraint == "users_email_key") { + return ErrUserAlreadyExists } return err } -// PromoteToPermament upgrades a temp user to permanent -func (s *Store) PromoteToPermanent(userID string) error { - query := `UPDATE users SET account_type = 'permanent', expires_at = NULL WHERE user_id = ?` - _, err := s.db.Exec(query, userID) - return err -} - -// userExists verifies username/email uniqueness within a transaction -func (s *Store) userExists(tx *sql.Tx, username, email string) (bool, error) { - var count int - query := `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE` - args := []any{username} - - if email != "" { - query = `SELECT COUNT(*) FROM users - WHERE username = ? COLLATE NOCASE - OR (email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '')` - args = append(args, email) +// DeleteUser removes a user synchronously; sessions cascade, while games keep +// the claim ID and name snapshot. It returns sql.ErrNoRows when no such user +// exists. +func (s *Store) DeleteUser(userID string) error { + if err := s.execUserUpdate(`DELETE FROM users WHERE user_id = $1`, userID); err != nil { + return err } - - err := tx.QueryRow(query, args...).Scan(&count) - if err != nil { - return false, err - } - return count > 0, nil + slog.Debug("storage user deleted", "user_id", userID) + return nil } // UpdateUserPassword updates user password hash -func (s *Store) UpdateUserPassword(userID string, passwordHash string) error { - query := `UPDATE users SET password_hash = ? WHERE user_id = ?` - _, err := s.db.Exec(query, passwordHash, userID) - return err +func (s *Store) UpdateUserPassword(userID, passwordHash string) error { + return s.execUserUpdate(`UPDATE users SET password_hash = $2 WHERE user_id = $1`, + userID, passwordHash) } -// UpdateUserEmail updates user email -func (s *Store) UpdateUserEmail(userID string, email string) error { - query := `UPDATE users SET email = ? WHERE user_id = ?` - _, err := s.db.Exec(query, email, userID) - return err +// UpdateUserEmail updates the email; an empty value removes it. +func (s *Store) UpdateUserEmail(userID, email string) error { + return s.execUserUpdate(`UPDATE users SET email = lower($2) WHERE user_id = $1`, + userID, nullableString(email)) } // UpdateUserUsername updates username -func (s *Store) UpdateUserUsername(userID string, username string) error { - query := `UPDATE users SET username = ? WHERE user_id = ?` - _, err := s.db.Exec(query, username, userID) - return err +func (s *Store) UpdateUserUsername(userID, username string) error { + return s.execUserUpdate(`UPDATE users SET username = lower($2) WHERE user_id = $1`, + userID, username) } -// GetAllUsers retrieves all users -func (s *Store) GetAllUsers() ([]UserRecord, error) { - query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at - FROM users ORDER BY created_at DESC` +// execUserUpdate runs a single-row statement keyed by user_id ($1). +func (s *Store) execUserUpdate(query, userID string, args ...any) error { + if !validUUID(userID) { + return sql.ErrNoRows + } + ctx, cancel := opContext() + defer cancel() + result, err := s.db.ExecContext(ctx, query, append([]any{userID}, args...)...) + if err != nil { + return mapUserConflict(err) + } + if rows, err := result.RowsAffected(); err != nil { + return err + } else if rows == 0 { + return sql.ErrNoRows + } + return nil +} - rows, err := s.db.Query(query) +// GetAllUsers retrieves all users, newest first +func (s *Store) GetAllUsers() ([]UserRecord, error) { + ctx, cancel := opContext() + defer cancel() + rows, err := s.db.QueryContext(ctx, + `SELECT `+userSelectColumns+` FROM users ORDER BY created_at DESC, user_id`) if err != nil { return nil, err } @@ -248,91 +175,57 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) { var users []UserRecord for rows.Next() { var user UserRecord - var email sql.NullString - err := rows.Scan( - &user.UserID, &user.Username, &email, - &user.PasswordHash, &user.AccountType, &user.CreatedAt, - &user.ExpiresAt, &user.LastLoginAt, - ) - if err != nil { + if err := scanUser(rows, &user); err != nil { return nil, err } - user.Email = email.String users = append(users, user) } - return users, rows.Err() } -// UpdateUserLastLoginSync updates user last login time -func (s *Store) UpdateUserLastLoginSync(userID string, loginTime time.Time) error { - query := `UPDATE users SET last_login_at = ? WHERE user_id = ?` - _, err := s.db.Exec(query, loginTime, userID) - if err != nil { - return fmt.Errorf("failed to update last login for user %s: %w", userID, err) - } - return nil -} - -// GetUserByUsername retrieves user by username with case-insensitive matching +// GetUserByUsername retrieves a user by case-insensitive username. func (s *Store) GetUserByUsername(username string) (*UserRecord, error) { - var user UserRecord - var email sql.NullString - query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at - FROM users WHERE username = ? COLLATE NOCASE` - - err := s.db.QueryRow(query, username).Scan( - &user.UserID, &user.Username, &email, - &user.PasswordHash, &user.AccountType, &user.CreatedAt, - &user.ExpiresAt, &user.LastLoginAt, - ) - if err != nil { - return nil, err - } - user.Email = email.String - return &user, nil + return s.getUser(`username = lower($1)`, username) } -// GetUserByEmail retrieves user by email with case-insensitive matching +// GetUserByEmail retrieves a user by case-insensitive email. func (s *Store) GetUserByEmail(email string) (*UserRecord, error) { - var user UserRecord - var emailNull sql.NullString - query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at - FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''` - - err := s.db.QueryRow(query, email).Scan( - &user.UserID, &user.Username, &emailNull, - &user.PasswordHash, &user.AccountType, &user.CreatedAt, - &user.ExpiresAt, &user.LastLoginAt, - ) - if err != nil { - return nil, err + if email == "" { + return nil, sql.ErrNoRows } - user.Email = emailNull.String - return &user, nil + return s.getUser(`email = lower($1)`, email) } // GetUserByID retrieves user by unique user ID func (s *Store) GetUserByID(userID string) (*UserRecord, error) { - var user UserRecord - var email sql.NullString - query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at - FROM users WHERE user_id = ?` + if !validUUID(userID) { + return nil, sql.ErrNoRows + } + return s.getUser(`user_id = $1`, userID) +} - err := s.db.QueryRow(query, userID).Scan( - &user.UserID, &user.Username, &email, - &user.PasswordHash, &user.AccountType, &user.CreatedAt, - &user.ExpiresAt, &user.LastLoginAt, - ) - if err != nil { +func (s *Store) getUser(predicate string, arg any) (*UserRecord, error) { + ctx, cancel := opContext() + defer cancel() + var user UserRecord + row := s.db.QueryRowContext(ctx, `SELECT `+userSelectColumns+` FROM users WHERE `+predicate, arg) + if err := scanUser(row, &user); err != nil { return nil, err } - user.Email = email.String return &user, nil } -// DeleteUser removes a user synchronously. Account operations are consistency -// sensitive and should not be reported successful before SQLite commits them. -func (s *Store) DeleteUser(userID string) error { - return s.DeleteUserByID(userID) +func scanUser(scanner rowScanner, user *UserRecord) error { + var email sql.NullString + var lastLoginAt sql.NullTime + if err := scanner.Scan( + &user.UserID, &user.Username, &email, + &user.PasswordHash, &user.CreatedAt, &lastLoginAt, + ); err != nil { + return err + } + user.Email = email.String + user.CreatedAt = user.CreatedAt.UTC() + user.LastLoginAt = utcPointer(lastLoginAt) + return nil } diff --git a/test/README.md b/test/README.md index 3bff0f7..fb6fdf2 100755 --- a/test/README.md +++ b/test/README.md @@ -6,9 +6,26 @@ This directory contains comprehensive test suites for the Chess API server, cove - `jq` - JSON processor - `curl` - HTTP client -- `sqlite3` - SQLite CLI (for database tests) +- `psql` - PostgreSQL client (for database tests) - `base64` - Base64 encoder (for JWT tests) +- Stockfish in `PATH` - Compiled `bin/chess-server` binary (or pass another path to the scripts) +- A **disposable** PostgreSQL database named by `CHESS_TEST_DSN` + +`CHESS_TEST_DSN` must be a libpq-compatible connection string, because the +scripts pass it to both `chess-server` and `psql`. The test server drops every +chess table in that database on start and exit, so never point it at real data. + +```bash +# One-time setup as a PostgreSQL superuser +psql -U postgres -c "CREATE ROLE chess_test LOGIN PASSWORD 'chess_test'" +psql -U postgres -c "CREATE DATABASE chess_test OWNER chess_test" + +export CHESS_TEST_DSN='postgres://chess_test:chess_test@localhost:5432/chess_test?sslmode=disable' +``` + +The same variable enables the Go database tests (`go test ./...`), which create +and drop an isolated schema per test and are skipped when it is unset. ## Running the test server From repo root @@ -17,8 +34,8 @@ test/run-test-server.sh ``` Pass the binary path as the first argument when it is not at `bin/chess-server`. -The server runs with `-dev`, debug logging, HTTP request logging, and relaxed rate limiting. WAL is enabled for every persistent server mode. -Will clean up test database and temporary files, so it's preferred for clean testing. +The server runs with `-dev`, debug logging, HTTP request logging, and relaxed rate limiting. +It drops the test tables on exit, so it's preferred for clean testing. Can be used for all the tests. Logging can be adjusted without editing the script: @@ -63,7 +80,7 @@ curl -X POST http://localhost:8080/games \ | API Functionality | `test-api.sh` | Game operations, moves, undo, rate limiting | | Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence | | Long-Polling | `test-longpoll.sh` | Real-time updates, wait behavior, timeouts | -| Test Server | `test-db-server.sh` | Pre-populated test environment | +| Test Server | `run-test-server.sh` | Pre-populated test environment | ## 1. API Functionality Tests (`test-api.sh`) @@ -97,8 +114,8 @@ Tests user management, authentication, and persistence via API integration. ### Running the test ```bash # Terminal 1: Start test server with database -# Server is running with -dev and persistent WAL storage -test/test-db-server.sh bin/chess-server +# Server is running with -dev and PostgreSQL storage +test/run-test-server.sh bin/chess-server # Terminal 2: Run API integration tests test/test-db.sh bin/chess-server @@ -114,12 +131,12 @@ test/test-db.sh bin/chess-server - **Database Schema**: Table creation, constraints, indexes ### Test Flow -1. Creates temporary `test.db` with schema +1. Re-runs the idempotent schema initialization against `CHESS_TEST_DSN` 2. Registers test users (alice, bob, charlie) 3. Tests authentication endpoints 4. Validates JWT tokens and claims 5. Tests duplicate user prevention -6. Cleans up test database +6. Leaves cleanup (dropping tables) to `run-test-server.sh` ## 3. Long-Polling Tests (`test-longpoll.sh`) @@ -129,7 +146,7 @@ Tests real-time game updates via HTTP long-polling. ```bash # Terminal 1: Start server with storage test/run-test-server.sh bin/chess-server -# Direct (test.db cleanup required): bin/chess-server -dev -storage-path test.db +# Direct (table cleanup required): CHESS_DSN="$CHESS_TEST_DSN" bin/chess-server -dev # Terminal 2: Run long-polling tests test/test-longpoll.sh diff --git a/test/run-test-server.sh b/test/run-test-server.sh index 35f3783..42318f3 100755 --- a/test/run-test-server.sh +++ b/test/run-test-server.sh @@ -4,7 +4,10 @@ set -e # Configuration CHESS_SERVER_EXEC=${1:-"bin/chess-server"} -TEST_DB="test.db" +# A DISPOSABLE database: every chess table in it is dropped on start and exit. +# Use a libpq-compatible DSN; test-db.sh also passes it to psql. +: "${CHESS_TEST_DSN:?set CHESS_TEST_DSN to a disposable PostgreSQL database}" +export CHESS_DSN="$CHESS_TEST_DSN" PID_FILE="/tmp/chess-server_test.pid" API_PORT=${API_PORT:-8080} LOG_LEVEL=${LOG_LEVEL:-debug} @@ -41,9 +44,9 @@ cleanup() { rm -f "$PID_FILE" fi - # Clean up database files - echo "Removing test database files..." - rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm" + # Drop test tables + echo "Dropping test database tables..." + "$CHESS_SERVER_EXEC" db delete -confirm >/dev/null || true echo -e "${GREEN}Cleanup complete${NC}" } @@ -51,13 +54,14 @@ cleanup() { # Set up trap for cleanup on exit trap cleanup EXIT SIGINT SIGTERM -# Clean slate - remove any existing test DB files +# Clean slate - drop tables left by an earlier run echo -e "${CYAN}Preparing test environment...${NC}" -rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm" "$PID_FILE" +rm -f "$PID_FILE" +"$CHESS_SERVER_EXEC" db delete -confirm # Initialize database echo -e "${CYAN}Initializing test database...${NC}" -"$CHESS_SERVER_EXEC" db init -path "$TEST_DB" +"$CHESS_SERVER_EXEC" db init if [ $? -ne 0 ]; then echo -e "${RED}Failed to initialize database${NC}" exit 1 @@ -65,14 +69,14 @@ fi # Add test users echo -e "${CYAN}Adding test users...${NC}" -"$CHESS_SERVER_EXEC" db user add -path "$TEST_DB" \ +"$CHESS_SERVER_EXEC" db user add \ -username alice -email alice@test.com -password AlicePass123 if [ $? -ne 0 ]; then echo -e "${RED}Failed to create user alice${NC}" exit 1 fi -"$CHESS_SERVER_EXEC" db user add -path "$TEST_DB" \ +"$CHESS_SERVER_EXEC" db user add \ -username bob -email bob@test.com -password BobSecure456 if [ $? -ne 0 ]; then echo -e "${RED}Failed to create user bob${NC}" @@ -90,9 +94,9 @@ echo -e "${CYAN}╚════════════════════ echo "" echo "Configuration:" echo " Executable: $CHESS_SERVER_EXEC" -echo " Database: $TEST_DB" +echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)" echo " Port: $API_PORT" -echo " Mode: Development (relaxed rate limits; persistent WAL storage)" +echo " Mode: Development (relaxed rate limits; PostgreSQL storage)" echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)" echo " Purpose: Backend for chess-server tests" echo " PID File: $PID_FILE" @@ -111,7 +115,6 @@ echo "" -dev \ -log-level "$LOG_LEVEL" \ -log-http="$LOG_HTTP" \ - -storage-path "$TEST_DB" \ -api-port "$API_PORT" \ -pid "$PID_FILE" \ -pid-lock diff --git a/test/test-db.sh b/test/test-db.sh index d2a4ac3..7753770 100755 --- a/test/test-db.sh +++ b/test/test-db.sh @@ -8,8 +8,11 @@ BASE_URL="http://localhost:8080" API_URL="${BASE_URL}/api/v1" -TEST_DB="test.db" CHESS_SERVER_EXEC=${1:-"bin/chess-server"} +# Same disposable database as the running test server (see run-test-server.sh). +# Use a libpq-compatible DSN: it is passed to both chess-server and psql. +: "${CHESS_TEST_DSN:?set CHESS_TEST_DSN to the test server database}" +export CHESS_DSN="$CHESS_TEST_DSN" API_DELAY=${API_DELAY:-50} # Colors @@ -121,7 +124,7 @@ api_request() { } # Check dependencies -for cmd in jq sqlite3 curl; do +for cmd in jq psql curl; do if ! command -v $cmd &> /dev/null; then echo -e "${RED}Error: $cmd is required but not installed${NC}" exit 1 @@ -145,7 +148,7 @@ fi print_header "Database & User Management Test Suite" echo "Server: $BASE_URL" echo "Executable: $CHESS_SERVER_EXEC" -echo "Test Database (server-managed): $TEST_DB" +echo "Test Database: \$CHESS_TEST_DSN (server-managed)" echo "" # ============================================================================== @@ -153,11 +156,11 @@ print_header "SECTION 1: CLI User Operations" # ============================================================================== test_case "1.1: database initialization" -assert_command "$CHESS_SERVER_EXEC db init -path $TEST_DB" 0 "initialize database" +assert_command "$CHESS_SERVER_EXEC db init" 0 "initialize database" # Create testuser1 first (not charlie) test_case "1.2: Add First User via CLI" -OUTPUT=$($CHESS_SERVER_EXEC db user add -path "$TEST_DB" -username "testuser1" \ +OUTPUT=$($CHESS_SERVER_EXEC db user add -username "testuser1" \ -email "testuser1@test.com" -password "TestPass123" 2>&1) if echo "$OUTPUT" | grep -qi "User created successfully"; then echo -e "${GREEN} ✓ User created: testuser1${NC}" @@ -168,7 +171,7 @@ else fi test_case "1.3: Add Second User" -OUTPUT=$($CHESS_SERVER_EXEC db user add -path "$TEST_DB" -username "testuser2" \ +OUTPUT=$($CHESS_SERVER_EXEC db user add -username "testuser2" \ -password "TestPass456" 2>&1) if echo "$OUTPUT" | grep -qi "User created successfully"; then echo -e "${GREEN} ✓ User created: testuser2${NC}" @@ -180,7 +183,7 @@ fi # Now test duplicate prevention with an existing user test_case "1.4: Duplicate Username Prevention" -assert_command "$CHESS_SERVER_EXEC db user add -path $TEST_DB -username testuser1 -password TestPass789" 1 \ +assert_command "$CHESS_SERVER_EXEC db user add -username testuser1 -password TestPass789" 1 \ "Duplicate username rejected" test_case "1.5: Login with Case-Insensitive Username (ALICE)" @@ -196,11 +199,11 @@ else fi test_case "1.6: Update User Email" -assert_command "$CHESS_SERVER_EXEC db user set-email -path $TEST_DB -username testuser2 -email testuser2_updated@test.com" 0 \ +assert_command "$CHESS_SERVER_EXEC db user set-email -username testuser2 -email testuser2_updated@test.com" 0 \ "Email update" test_case "1.7: Update User Password" -assert_command "$CHESS_SERVER_EXEC db user set-password -path $TEST_DB -username testuser2 -password NewPass789" 0 \ +assert_command "$CHESS_SERVER_EXEC db user set-password -username testuser2 -password NewPass789" 0 \ "Password update" test_case "2.1: Health Check" @@ -324,7 +327,7 @@ test_case "4.1: Verify Game Storage with User ID" if [ -n "$GAME_ID" ]; then sleep 0.5 # Allow async write - DB_WHITE_ID=$(sqlite3 "$TEST_DB" "SELECT white_player_id FROM games WHERE game_id = '$GAME_ID';" 2>/dev/null) + DB_WHITE_ID=$(psql "$CHESS_TEST_DSN" -XAtqc "SELECT white_player_id FROM games WHERE game_id = '$GAME_ID';" 2>/dev/null) if [ "$DB_WHITE_ID" = "$USER_ID_ALICE" ]; then echo -e "${GREEN} ✓ User ID correctly persisted in database${NC}" ((PASS++)) @@ -335,7 +338,7 @@ if [ -n "$GAME_ID" ]; then fi test_case "4.2: Query Games by User ID" -GAMES_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM games WHERE white_player_id = '$USER_ID_ALICE' OR black_player_id = '$USER_ID_ALICE';" 2>/dev/null) +GAMES_COUNT=$(psql "$CHESS_TEST_DSN" -XAtqc "SELECT COUNT(*) FROM games WHERE white_player_id = '$USER_ID_ALICE' OR black_player_id = '$USER_ID_ALICE';" 2>/dev/null) if [ "$GAMES_COUNT" -ge "2" ]; then echo -e "${GREEN} ✓ User's games queryable: found $GAMES_COUNT games${NC}" ((PASS++)) @@ -350,7 +353,7 @@ print_header "SECTION 5: Password Operations" # Now TEST_PASS2_NEW is defined, this test should work test_case "5.1: Update User Password via CLI for 'bob'" -assert_command "$CHESS_SERVER_EXEC db user set-password -path $TEST_DB -username $TEST_USER2 -password $TEST_PASS2_NEW" 0 \ +assert_command "$CHESS_SERVER_EXEC db user set-password -username $TEST_USER2 -password $TEST_PASS2_NEW" 0 \ "CLI password update for '$TEST_USER2'" test_case "5.2: Login with NEW Password for 'bob'" @@ -373,11 +376,11 @@ STATUS=$(api_request POST "$API_URL/auth/login" \ assert_status 401 "$STATUS" "Old password correctly rejected for '$TEST_USER2'" test_case "5.4: Add new user '$TEST_USER_CLI' via CLI for hash test" -assert_command "$CHESS_SERVER_EXEC db user add -path $TEST_DB -username $TEST_USER_CLI -password $TEST_PASS_CLI" 0 \ +assert_command "$CHESS_SERVER_EXEC db user add -username $TEST_USER_CLI -password $TEST_PASS_CLI" 0 \ "Add user '$TEST_USER_CLI' for hash test" test_case "5.5: CLI rejects unsupported hash format" -assert_command "$CHESS_SERVER_EXEC db user set-hash -path $TEST_DB -username $TEST_USER_CLI -hash '$UNSUPPORTED_HASH'" 1 \ +assert_command "$CHESS_SERVER_EXEC db user set-hash -username $TEST_USER_CLI -hash '$UNSUPPORTED_HASH'" 1 \ "Unsupported bcrypt hash rejected by CLI" # ============================================================================== @@ -409,7 +412,7 @@ test_case "6.2: Concurrent Registration Handling" wait # Check only one user was created -USER_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM users WHERE username = 'concurrent';" 2>/dev/null) +USER_COUNT=$(psql "$CHESS_TEST_DSN" -XAtqc "SELECT COUNT(*) FROM users WHERE username = 'concurrent';" 2>/dev/null) if [ "$USER_COUNT" = "1" ]; then echo -e "${GREEN} ✓ Concurrent registration handled correctly${NC}" ((PASS++)) @@ -420,15 +423,15 @@ fi test_case "6.3: Delete User" # First, get a user to delete -OUTPUT=$($CHESS_SERVER_EXEC db user add -path "$TEST_DB" -username "deleteme" \ +OUTPUT=$($CHESS_SERVER_EXEC db user add -username "deleteme" \ -password "TempPass123" 2>&1) TEMP_ID=$(echo "$OUTPUT" | grep "ID:" | awk '{print $2}') -assert_command "$CHESS_SERVER_EXEC db user delete -path $TEST_DB -username deleteme" 0 \ +assert_command "$CHESS_SERVER_EXEC db user delete -username deleteme" 0 \ "User deletion by username" # Verify deletion -USER_EXISTS=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM users WHERE user_id = '$TEMP_ID';" 2>/dev/null) +USER_EXISTS=$(psql "$CHESS_TEST_DSN" -XAtqc "SELECT COUNT(*) FROM users WHERE user_id = '$TEMP_ID';" 2>/dev/null) if [ "$USER_EXISTS" = "0" ]; then echo -e "${GREEN} ✓ User successfully deleted from database${NC}" ((PASS++))