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

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

No files matched your search

+2
View File
@@ -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"`
+1 -1
View File
@@ -175,4 +175,4 @@ func setUserHandler(s *session.Session, args []string) error {
fmt.Println("Note: This doesn't authenticate, just sets the ID for display")
return nil
}
}
+1 -1
View File
@@ -89,4 +89,4 @@ func rawRequestHandler(s *session.Session, args []string) error {
c := s.GetClient().(*api.Client)
return c.RawRequest(method, path, body)
}
}
+1 -1
View File
@@ -16,4 +16,4 @@ func readPassword(prompt string) (string, error) {
return "", err
}
return string(bytePassword), nil
}
}
+1 -1
View File
@@ -25,4 +25,4 @@ func readPassword(prompt string) (string, error) {
return "", err
}
return "", fmt.Errorf("no input received")
}
}
+1 -1
View File
@@ -171,4 +171,4 @@ func exitHandler(s *session.Session, args []string) error {
// Exit is handled in main loop, this is just for consistency
display.Println(display.Cyan, "Goodbye!\n")
return nil
}
}
+1 -1
View File
@@ -50,4 +50,4 @@ func ColorForTurn(turn string) string {
return Blue + "White" + Reset
}
return Red + "Black" + Reset
}
}
+1 -1
View File
@@ -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
}
}
+1 -1
View File
@@ -13,4 +13,4 @@ func PrettyPrintJSON(v any) {
return
}
fmt.Println(string(data))
}
}
+1 -1
View File
@@ -40,4 +40,4 @@ func (s *Session) SetGameState(game any) {
}
}
func (s *Session) SetPlayerColor(color string) { s.PlayerColor = color }
func (s *Session) GetPlayerColor() string { return s.PlayerColor }
func (s *Session) GetPlayerColor() string { return s.PlayerColor }
+1 -1
View File
@@ -116,4 +116,4 @@ func (b *Board) GetPieceAt(square string) byte {
file := square[0] - 'a'
rank := '8' - square[1]
return b.squares[rank][file]
}
}
+3 -1
View File
@@ -69,10 +69,12 @@ type HistoryMove struct {
}
// GameSummary is intentionally sufficient for a client-side game picker; the
// 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"`
+2 -1
View File
@@ -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
}
}
-1
View File
@@ -284,4 +284,3 @@ func (u *UCI) Close() error {
u.killLocked()
return nil
}
+29 -18
View File
@@ -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 = &copy
}
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]
+10 -5
View File
@@ -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),
})
}
+53 -29
View File
@@ -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",
+61
View File
@@ -0,0 +1,61 @@
package http
import (
"io"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestClientIPTrustsProxyHeaderOnlyFromTrustedPeers(t *testing.T) {
// app.Test connections originate from 0.0.0.0.
for name, test := range map[string]struct {
opts Options
want string
}{
"no proxy configured": {Options{ProxyHeader: "X-Real-IP"}, "0.0.0.0"},
"untrusted peer": {Options{TrustedProxies: []string{"10.0.0.1"}, ProxyHeader: "X-Real-IP"}, "0.0.0.0"},
"trusted peer": {Options{TrustedProxies: []string{"0.0.0.0/32"}, ProxyHeader: "X-Real-IP"}, "203.0.113.9"},
"trusted, header only": {Options{TrustedProxies: []string{"0.0.0.0"}, ProxyHeader: "X-Real-IP"}, "203.0.113.9"},
} {
app := fiber.New(appConfig(test.opts))
app.Get("/ip", func(c *fiber.Ctx) error { return c.SendString(clientIP(c)) })
request := httptest.NewRequest("GET", "/ip", nil)
request.Header.Set("X-Real-IP", "203.0.113.9")
request.Header.Set("X-Forwarded-For", "198.51.100.1") // never consulted
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(response.Body)
if string(body) != test.want {
t.Errorf("%s: client IP = %q, want %q", name, body, test.want)
}
}
// A trusted proxy that omits the header must not collapse clients into one key.
app := fiber.New(appConfig(Options{TrustedProxies: []string{"0.0.0.0"}, ProxyHeader: "X-Real-IP"}))
app.Get("/ip", func(c *fiber.Ctx) error { return c.SendString(clientIP(c)) })
response, err := app.Test(httptest.NewRequest("GET", "/ip", nil))
if err != nil {
t.Fatal(err)
}
if body, _ := io.ReadAll(response.Body); string(body) != "0.0.0.0" {
t.Errorf("missing proxy header: client IP = %q, want peer address", body)
}
}
func TestIsValidUUIDRequiresCanonicalForm(t *testing.T) {
for value, want := range map[string]bool{
"5579b47e-4d3b-4eb3-abbd-846fe94cb955": true,
"{5579b47e-4d3b-4eb3-abbd-846fe94cb955}": false,
"urn:uuid:5579b47e-4d3b-4eb3-abbd-846fe94cb955": false,
"5579b47e4d3b4eb3abbd846fe94cb955": false,
"not-a-uuid": false,
} {
if got := isValidUUID(value); got != want {
t.Errorf("isValidUUID(%q) = %v, want %v", value, got, want)
}
}
}
+35 -45
View File
@@ -2,12 +2,12 @@ package http
import (
"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,
})
}
+33
View File
@@ -58,3 +58,36 @@ func TestAuthMiddlewareReportsStorageUnavailable(t *testing.T) {
}
}
}
func TestAuthMiddlewareParsesBearerHeaderStrictly(t *testing.T) {
validate := func(token string) (string, map[string]any, error) {
if token != "good.token.value" {
return "", nil, errors.New("invalid token")
}
return "user", map[string]any{"session_id": "session"}, nil
}
for _, middleware := range []func(TokenValidator) fiber.Handler{AuthRequired, OptionalAuth} {
app := fiber.New()
app.Get("/route", middleware(validate), func(c *fiber.Ctx) error {
return c.SendString(c.Locals("userID").(string))
})
for header, want := range map[string]int{
"Bearer good.token.value": fiber.StatusOK,
"bearer good.token.value": fiber.StatusOK, // RFC 6750 scheme is case-insensitive
"Basic dXNlcjpwYXNz": fiber.StatusUnauthorized,
"Bearer good token": fiber.StatusUnauthorized,
"Bearer ": fiber.StatusUnauthorized,
"good.token.value": fiber.StatusUnauthorized,
} {
request := httptest.NewRequest("GET", "/route", nil)
request.Header.Set("Authorization", header)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != want {
t.Errorf("header %q: status = %d, want %d", header, response.StatusCode, want)
}
}
}
}
+7 -1
View File
@@ -96,7 +96,13 @@ func validationMiddleware(c *fiber.Ctx) error {
return c.Next()
}
// 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
}
}
+1 -1
View File
@@ -83,4 +83,4 @@ func NewGetBoardCommand(gameID string) Command {
Type: CmdGetBoard,
GameID: gameID,
}
}
}
-39
View File
@@ -531,25 +531,6 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
})
}
// determineGameEndState centralized function to determine game end state based on engine evaluation
func (p *Processor) determineGameEndState(lastMoveBy core.Color, searchResult *engine.SearchResult) core.State {
// No legal moves detected
if searchResult.BestMove == "" || searchResult.BestMove == "(none)" {
if searchResult.IsMate {
// It's a checkmate - the side that just moved wins
if lastMoveBy == core.ColorWhite {
return core.StateWhiteWins
}
return core.StateBlackWins
}
// Stalemate - no legal moves but not in check
return core.StateStalemate
}
// Game continues
return core.StateOngoing
}
// classifyCurrentLocked classifies whatever position is loaded in the
// 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{
+2 -14
View File
@@ -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())
+86 -28
View File
@@ -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)
+3
View File
@@ -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,
},
}
}
+85 -84
View File
@@ -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)
}
}
}
+120 -189
View File
@@ -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
}
+114 -14
View File
@@ -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")
}
}
+204 -214
View File
@@ -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":
+73
View File
@@ -0,0 +1,73 @@
// Package pgtest provisions isolated PostgreSQL schemas for tests.
//
// Set CHESS_TEST_DSN to a database the test role may create schemas in, e.g.
//
// CHESS_TEST_DSN='postgres://chess_test:secret@127.0.0.1:5432/chess_test?sslmode=disable'
//
// Tests that need a database are skipped when it is unset.
package pgtest
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"net/url"
"os"
"strings"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
// EnvDSN names the environment variable holding the base test DSN.
const EnvDSN = "CHESS_TEST_DSN"
// DSN creates an empty schema, registers its removal as test cleanup, and
// returns the base DSN with search_path pinned to that schema.
func DSN(t testing.TB) string {
t.Helper()
base := os.Getenv(EnvDSN)
if base == "" {
t.Skipf("%s is not set; skipping PostgreSQL test", EnvDSN)
}
suffix := make([]byte, 6)
rand.Read(suffix)
schema := "chess_test_" + hex.EncodeToString(suffix)
admin, err := sql.Open("pgx", base)
if err != nil {
t.Fatalf("open test database: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := admin.ExecContext(ctx, "CREATE SCHEMA "+schema); err != nil {
admin.Close()
t.Fatalf("create test schema: %v", err)
}
t.Cleanup(func() {
defer admin.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := admin.ExecContext(ctx, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
t.Errorf("drop test schema %s: %v", schema, err)
}
})
return WithParam(base, "search_path", schema)
}
// WithParam adds a runtime parameter to a URL or keyword/value DSN.
func WithParam(dsn, key, value string) string {
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
u, err := url.Parse(dsn)
if err == nil {
query := u.Query()
query.Set(key, value)
u.RawQuery = query.Encode()
return u.String()
}
}
return dsn + " " + key + "=" + value
}
+124 -95
View File
@@ -2,59 +2,62 @@ package storage
import "time"
// 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"}
+50 -61
View File
@@ -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
}
+256 -173
View File
@@ -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(&currentVersion); 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, &notNull, &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
}
File diff suppressed because it is too large. Load diff
+122 -229
View File
@@ -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
}