v0.11.0 harden persistence and prepare game replays

This commit is contained in:
2026-09-07 14:39:00 -04:00
parent 21dea47694
commit 5d2be4abb2
42 changed files with 3591 additions and 693 deletions
+12 -12
View File
@@ -91,9 +91,14 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
req.Email = strings.ToLower(req.Email)
}
// Create user (temp by default via API)
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password, false)
// 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)
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) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "registration temporarily unavailable",
@@ -114,15 +119,6 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
})
}
// Create session for new user
sessionID, err := h.svc.CreateUserSession(user.UserID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to create session",
Code: core.ErrInternalError,
})
}
// Generate JWT token
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
@@ -193,6 +189,11 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
// Authenticate user and create session (invalidates previous session)
user, sessionID, err := h.svc.AuthenticateUser(req.Identifier, 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,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid credentials",
Code: core.ErrInvalidRequest,
@@ -263,4 +264,3 @@ func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "logged out"})
}
+104 -10
View File
@@ -1,6 +1,7 @@
package http
import (
"errors"
"fmt"
"strconv"
"strings"
@@ -29,7 +30,7 @@ func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandle
return &HTTPHandler{proc: proc, svc: svc}
}
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App {
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRequests bool) *fiber.App {
// Create handler
h := NewHTTPHandler(proc, svc)
@@ -43,9 +44,13 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
// Global middleware (order matters)
app.Use(recover.New())
app.Use(logger.New(logger.Config{
Format: "${time} ${status} ${method} ${path} ${latency}\n",
}))
if logRequests {
app.Use(logger.New(logger.Config{
Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n",
TimeFormat: time.RFC3339,
TimeZone: "UTC",
}))
}
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
@@ -136,12 +141,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
// Register game routes with auth middleware
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
api.Get("/games/:gameId/history", h.GetGameHistory)
api.Put("/games/:gameId/players", h.ConfigurePlayers)
api.Get("/games/:gameId", h.GetGame)
api.Delete("/games/:gameId", h.DeleteGame)
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
api.Post("/games/:gameId/undo", h.UndoMove)
api.Get("/games/:gameId/board", h.GetBoard)
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
return app
}
@@ -191,10 +198,15 @@ func customErrorHandler(c *fiber.Ctx, err error) error {
// Health check endpoint with storage status
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
storageHealth := h.svc.GetStorageHealth()
status := "healthy"
if storageHealth == "degraded" {
status = "degraded"
}
return c.JSON(fiber.Map{
"status": "healthy",
"status": status,
"time": time.Now().Unix(),
"storage": h.svc.GetStorageHealth(),
"storage": storageHealth,
})
}
@@ -324,7 +336,7 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
}
// First check if game exists and get current state
g, err := h.svc.GetGame(gameID)
g, err := h.svc.GetGameView(gameID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game not found",
@@ -332,8 +344,8 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
})
}
currentMoveCount := len(g.Moves())
st := g.State()
currentMoveCount := len(g.Moves)
st := g.State
settled := st != core.StateOngoing && st != core.StatePending
// If move count already different, return immediately
if moveCount != currentMoveCount || settled {
@@ -414,6 +426,8 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
statusCode = fiber.StatusNotFound
case core.ErrUnauthorized:
statusCode = fiber.StatusForbidden
case core.ErrConflict:
statusCode = fiber.StatusConflict
}
return c.Status(statusCode).JSON(resp.Error)
}
@@ -470,7 +484,7 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
return c.JSON(resp.Data)
}
// DeleteGame ends and cleans up a game
// DeleteGame unloads a live game while retaining its durable history.
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
gameID := c.Params("gameId")
@@ -519,3 +533,83 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
return c.JSON(resp.Data)
}
// GetGameHistory serves persisted replay data. Histories are public by game ID,
// matching the existing public live-game read model.
func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
gameID := c.Params("gameId")
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format", Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
}
history, err := h.svc.GetGameHistory(gameID)
if err != nil {
switch {
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "game history storage unavailable", Code: core.ErrStorageUnavailable,
})
case errors.Is(err, service.ErrGameNotFound):
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game history not found", Code: core.ErrGameNotFound,
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to load game history", Code: core.ErrInternalError,
})
}
}
return c.JSON(history)
}
// GetCurrentUserGames returns a bounded list suitable for CLI and web game
// pickers. The extra row used to compute nextOffset stays internal.
func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(string)
if !ok || userID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "unauthorized", Code: core.ErrUnauthorized,
})
}
limit, err := queryInt(c, "limit", 50, 1, 100)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid pagination", Code: core.ErrInvalidRequest, Details: err.Error(),
})
}
offset, err := queryInt(c, "offset", 0, 0, 1_000_000)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid pagination", Code: core.ErrInvalidRequest, Details: err.Error(),
})
}
games, err := h.svc.GetUserGames(userID, limit, offset)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to load stored games", Code: core.ErrInternalError,
})
}
return c.JSON(games)
}
func queryInt(c *fiber.Ctx, name string, defaultValue, minimum, maximum int) (int, error) {
raw := c.Query(name)
if raw == "" {
return defaultValue, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value < minimum || value > maximum {
return 0, fmt.Errorf("%s must be between %d and %d", name, minimum, maximum)
}
return value, nil
}
+25 -8
View File
@@ -1,9 +1,11 @@
package http
import (
"errors"
"strings"
"chess/internal/server/core"
"chess/internal/server/service"
"github.com/gofiber/fiber/v2"
)
@@ -18,15 +20,20 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "missing authorization token",
Code: core.ErrInvalidRequest,
Code: core.ErrUnauthorized,
})
}
userID, claims, err := validateToken(token)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token",
Code: core.ErrInvalidRequest,
Code: core.ErrUnauthorized,
})
}
@@ -38,7 +45,8 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
}
}
// OptionalAuth validates JWT if present but allows anonymous access
// OptionalAuth permits an absent token but rejects an invalid token instead of
// silently downgrading an intended authenticated request to anonymous access.
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
return func(c *fiber.Ctx) error {
token := extractBearerToken(c.Get("Authorization"))
@@ -47,11 +55,20 @@ func OptionalAuth(validateToken TokenValidator) fiber.Handler {
}
userID, claims, err := validateToken(token)
if err == nil {
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
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,
})
}
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
}
return c.Next()
}
@@ -64,4 +81,4 @@ func extractBearerToken(header string) string {
return ""
}
return strings.TrimPrefix(header, prefix)
}
}
+60
View File
@@ -0,0 +1,60 @@
package http
import (
"errors"
"net/http/httptest"
"testing"
"chess/internal/server/service"
"github.com/gofiber/fiber/v2"
)
func TestOptionalAuthAllowsAbsenceButRejectsInvalidToken(t *testing.T) {
app := fiber.New()
app.Get("/optional", OptionalAuth(func(string) (string, map[string]any, error) {
return "", nil, errors.New("invalid token")
}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
})
request := httptest.NewRequest("GET", "/optional", nil)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusNoContent {
t.Fatalf("anonymous status = %d, want %d", response.StatusCode, fiber.StatusNoContent)
}
request = httptest.NewRequest("GET", "/optional", nil)
request.Header.Set("Authorization", "Bearer invalid")
response, err = app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("invalid-token status = %d, want %d", response.StatusCode, fiber.StatusUnauthorized)
}
}
func TestAuthMiddlewareReportsStorageUnavailable(t *testing.T) {
for _, middleware := range []func(TokenValidator) fiber.Handler{AuthRequired, OptionalAuth} {
app := fiber.New()
app.Get("/protected", middleware(func(string) (string, map[string]any, error) {
return "", nil, service.ErrStorageUnavailable
}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
})
request := httptest.NewRequest("GET", "/protected", nil)
request.Header.Set("Authorization", "Bearer token")
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", response.StatusCode, fiber.StatusServiceUnavailable)
}
}
}