v0.9.0 user and session management improvement, xterm.js addons

This commit is contained in:
2026-02-07 15:37:52 -05:00
parent 0a85cc88bb
commit 820ad7eb27
62 changed files with 875 additions and 446 deletions

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/board/board.go
package board
import (

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/core/api.go
package core
// Request types

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/core/error.go
package core
// Error codes
@ -12,4 +11,6 @@ const (
ErrInvalidRequest = "INVALID_REQUEST"
ErrInvalidFEN = "INVALID_FEN"
ErrInternalError = "INTERNAL_ERROR"
ErrResourceLimit = "RESOURCE_LIMIT"
ErrUnauthorized = "UNAUTHORIZED"
)

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/core/player.go
package core
import (
@ -19,6 +18,7 @@ type Player struct {
Type PlayerType `json:"type"`
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
}
// PlayerConfig for API requests and configuration
@ -28,7 +28,7 @@ type PlayerConfig struct {
SearchTime int `json:"searchTime,omitempty" validate:"omitempty,min=100,max=10000"` // Processor sets the min value
}
// PlayersResponse for API responses - now contains full Player structs
// PlayersResponse for API responses
type PlayersResponse struct {
White *Player `json:"white"`
Black *Player `json:"black"`
@ -50,10 +50,20 @@ func NewPlayer(config PlayerConfig, color Color) *Player {
return player
}
// IsClaimed returns true if this slot has been claimed by a user
func (p *Player) IsClaimed() bool {
return p.ClaimedBy != ""
}
// CanBeClaimed returns true if this slot can be claimed
func (p *Player) CanBeClaimed() bool {
return p.Type == PlayerHuman && !p.IsClaimed()
}
type Color byte
const (
ColorWhite = iota + 1
ColorWhite Color = iota + 1
ColorBlack
)

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/core/state.go
package core
type State int

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/engine/engine.go
package engine
import (

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/game/game.go
package game
import (
@ -149,4 +148,47 @@ func (g *Game) InitialFEN() string {
return g.snapshots[0].FEN
}
return board.StartingFEN
}
// ClaimSlot claims a player slot for a user
// Caller must hold the lock
func (g *Game) ClaimSlot(color core.Color, userID string) error {
player := g.players[color]
if player == nil {
return fmt.Errorf("invalid color")
}
if player.Type != core.PlayerHuman {
return fmt.Errorf("cannot claim computer slot")
}
if player.ClaimedBy != "" && player.ClaimedBy != userID {
return fmt.Errorf("slot already claimed by another user")
}
player.ClaimedBy = userID
return nil
}
// GetSlotOwner returns the userID that claimed the slot, empty if unclaimed
// Caller must hold the lock
func (g *Game) GetSlotOwner(color core.Color) string {
player := g.players[color]
if player == nil {
return ""
}
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]
black := g.players[core.ColorBlack]
return (white != nil && white.Type == core.PlayerComputer) ||
(black != nil && black.Type == core.PlayerComputer)
}

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/http/auth.go
package http
import (
@ -9,6 +8,7 @@ import (
"unicode"
"chess/internal/server/core"
"chess/internal/server/service"
"github.com/gofiber/fiber/v2"
)
@ -90,8 +90,8 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
req.Email = strings.ToLower(req.Email)
}
// Create user
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password)
// Create user (temp by default via API)
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password, false)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
@ -100,14 +100,30 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
Details: "username or email already taken",
})
}
if strings.Contains(err.Error(), "limit") || strings.Contains(err.Error(), "capacity") {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "registration temporarily unavailable",
Code: core.ErrResourceLimit,
Details: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to create user",
Code: core.ErrInternalError,
})
}
// 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)
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to generate token",
@ -120,7 +136,7 @@ func (h *HTTPHandler) RegisterHandler(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),
})
}
@ -173,18 +189,17 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
// Normalize identifier for case-insensitive lookup
req.Identifier = strings.ToLower(req.Identifier)
// Authenticate user
user, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
// Authenticate user and create session (invalidates previous session)
user, sessionID, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
if err != nil {
// Always return same error to prevent user enumeration
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid credentials",
Code: core.ErrInvalidRequest,
})
}
// Generate JWT token
token, err := h.svc.GenerateUserToken(user.UserID)
// Generate JWT token with session ID
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to generate token",
@ -192,10 +207,6 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
})
}
// Update last login
// TODO: for now, non-blocking if login time update fails, log/block in the future
_ = h.svc.UpdateLastLogin(user.UserID)
return c.JSON(AuthResponse{
Token: token,
UserID: user.UserID,
@ -229,4 +240,25 @@ func (h *HTTPHandler) GetCurrentUserHandler(c *fiber.Ctx) error {
Email: user.Email,
CreatedAt: user.CreatedAt,
})
}
// LogoutHandler invalidates the current session
func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error {
// Extract session ID from token claims
sessionID, ok := c.Locals("sessionID").(string)
if !ok || sessionID == "" {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "no active session",
Code: core.ErrInvalidRequest,
})
}
if err := h.svc.InvalidateSession(sessionID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to logout",
Code: core.ErrInternalError,
})
}
return c.JSON(fiber.Map{"message": "logged out"})
}

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/http/handler.go
package http
import (
@ -100,6 +99,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
// Current user (requires auth)
auth.Get("/me", AuthRequired(validateToken), h.GetCurrentUserHandler)
// Logout
auth.Post("/logout", AuthRequired(validateToken), h.LogoutHandler)
// Game routes with standard rate limiting
maxReq := rateLimitRate
if devMode {
@ -137,7 +139,7 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
api.Put("/games/:gameId/players", h.ConfigurePlayers)
api.Get("/games/:gameId", h.GetGame)
api.Delete("/games/:gameId", h.DeleteGame)
api.Post("/games/:gameId/moves", h.MakeMove)
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
api.Post("/games/:gameId/undo", h.UndoMove)
api.Get("/games/:gameId/board", h.GetBoard)
@ -370,7 +372,6 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
gameID := c.Params("gameId")
// Validate UUID format
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format",
@ -379,7 +380,6 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
})
}
// Ensure middleware validation ran
validated, ok := c.Locals("validated").(bool)
if !ok || !validated {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
@ -388,7 +388,6 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
})
}
// Retrieve validated parsed body
validatedBody := c.Locals("validatedBody")
if validatedBody == nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
@ -399,15 +398,21 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
var req core.MoveRequest
req = *(validatedBody.(*core.MoveRequest))
// Create command and execute
// Get authenticated user ID if present
userID, _ := c.Locals("userID").(string)
cmd := processor.NewMakeMoveCommand(gameID, req)
cmd.UserID = userID // Pass user context for authorization
resp := h.proc.Execute(cmd)
// Return appropriate HTTP response with correct status code
if !resp.Success {
statusCode := fiber.StatusBadRequest
if resp.Error.Code == core.ErrGameNotFound {
switch resp.Error.Code {
case core.ErrGameNotFound:
statusCode = fiber.StatusNotFound
case core.ErrUnauthorized:
statusCode = fiber.StatusForbidden
}
return c.Status(statusCode).JSON(resp.Error)
}

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/http/middleware.go
package http
import (
@ -23,7 +22,7 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
})
}
userID, _, err := validateToken(token)
userID, claims, err := validateToken(token)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token",
@ -32,6 +31,9 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
}
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
}
return c.Next()
}
}
@ -44,11 +46,13 @@ func OptionalAuth(validateToken TokenValidator) fiber.Handler {
return c.Next()
}
userID, _, err := validateToken(token)
userID, claims, err := validateToken(token)
if err == nil {
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
}
}
// Continue regardless of token validity
return c.Next()
}
}

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/http/handler.go
package http
import (

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/processor/command.go
package processor
import (

View File

@ -1,5 +1,3 @@
// FILE: lixenwraith/chess/internal/server/processor/processor.go
package processor
import (
@ -131,6 +129,15 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
args.Black.SearchTime = minSearchTime
}
// Check computer game limit
hasComputer := args.White.Type == core.PlayerComputer || args.Black.Type == core.PlayerComputer
if hasComputer && !p.svc.CanCreateComputerGame() {
return p.errorResponse(
fmt.Sprintf("computer game limit reached (%d/%d)", p.svc.GetComputerGameCount(), service.MaxComputerGames),
core.ErrResourceLimit,
)
}
// Generate game ID
gameID := p.svc.GenerateGameID()
@ -163,12 +170,17 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
// Override player IDs for authenticated human players
if args.White.Type == core.PlayerHuman && cmd.UserID != "" {
whitePlayer.ID = cmd.UserID
}
if args.Black.Type == core.PlayerHuman && cmd.UserID != "" {
blackPlayer.ID = cmd.UserID
// FIX: Only assign authenticated user to ONE human slot
// If both are human, authenticated user gets white; black remains unclaimed
if cmd.UserID != "" {
if args.White.Type == core.PlayerHuman {
whitePlayer.ID = cmd.UserID
whitePlayer.ClaimedBy = cmd.UserID
} else if args.Black.Type == core.PlayerHuman {
// Only claim black if white is not human (i.e., H vs C scenario)
blackPlayer.ID = cmd.UserID
blackPlayer.ClaimedBy = cmd.UserID
}
}
// Create game in service with fully-formed players
@ -252,7 +264,7 @@ func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
}
}
// handleMakeMove processes human moves
// handleMakeMove processes human moves with authorization
func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
args, ok := cmd.Args.(core.MoveRequest)
if !ok {
@ -278,21 +290,22 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse("game is in invalid state", core.ErrInvalidRequest)
}
// Handle empty move string - trigger computer move
currentColor := g.NextTurnColor()
currentPlayer := g.NextPlayer()
// Handle computer move trigger
if strings.TrimSpace(args.Move) == "cccc" {
if g.NextPlayer().Type != core.PlayerComputer {
if currentPlayer.Type != core.PlayerComputer {
return p.errorResponse("not computer player's turn", core.ErrNotHumanTurn)
}
// Set state to pending and trigger computer move
p.svc.UpdateGameState(cmd.GameID, core.StatePending)
p.triggerComputerMove(cmd.GameID, g)
// Re-fetch for updated state
g, _ = p.svc.GetGame(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
response.LastMove = &core.MoveInfo{
PlayerColor: g.NextTurnColor().String(),
PlayerColor: currentColor.String(),
}
return ProcessorResponse{
@ -302,11 +315,32 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
}
}
// Handle human move
if g.NextPlayer().Type != core.PlayerHuman {
// Human move - validate authorization
if currentPlayer.Type != core.PlayerHuman {
return p.errorResponse("not human player's turn", core.ErrNotHumanTurn)
}
// Authorization: first-move-claims-slot model
slotOwner := g.GetSlotOwner(currentColor)
if slotOwner == "" {
// Slot unclaimed - claim it with this move
if cmd.UserID != "" {
if err := p.svc.ClaimGameSlot(cmd.GameID, currentColor, cmd.UserID); err != nil {
return p.errorResponse(fmt.Sprintf("failed to claim slot: %v", err), core.ErrInternalError)
}
}
// Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds)
} else if cmd.UserID != "" && slotOwner != cmd.UserID {
// Slot claimed by different user
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
}
// If slotOwner == cmd.UserID, authorized to proceed
// If slotOwner != "" && cmd.UserID == "", anonymous trying to move claimed slot - block
if slotOwner != "" && cmd.UserID == "" {
return p.errorResponse("slot claimed - authentication required", core.ErrUnauthorized)
}
// Normalize and validate move format
move := strings.ToLower(strings.TrimSpace(args.Move))
if !p.isMoveSafe(move) {
@ -314,7 +348,6 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
}
currentFEN := g.CurrentFEN()
currentColor := g.NextTurnColor()
// Validate move with engine
p.mu.Lock()

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/processor/queue.go
package processor
import (

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/service/game.go
package service
import (
@ -21,6 +20,15 @@ func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, i
return fmt.Errorf("game %s already exists", id)
}
// Check computer game limit
hasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
if hasComputer {
if s.computerGames.Load() >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
}
s.computerGames.Add(1)
}
// Store game with provided players
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
@ -186,16 +194,22 @@ func (s *Service) UndoMoves(gameID string, count int) error {
return nil
}
// DeleteGame removes a game from memory
// DeleteGame removes a game from the service
func (s *Service) DeleteGame(gameID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.games[gameID]; !ok {
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
}
// Notify and remove all waiters before deletion
// Decrement computer game count if applicable
if g.HasComputerPlayer() {
s.computerGames.Add(-1)
}
// Remove from wait registry
s.waiter.RemoveGame(gameID)
delete(s.games, gameID)

View File

@ -1,24 +1,35 @@
// FILE: lixenwraith/chess/internal/server/service/service.go
package service
import (
"chess/internal/server/core"
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"chess/internal/server/game"
"chess/internal/server/storage"
)
// Service is a pure state manager for chess games with optional persistence
const (
MaxComputerGames = 10
MaxUsers = 100
PermanentSlots = 10
TempUserTTL = 24 * time.Hour
SessionTTL = 7 * 24 * time.Hour
CleanupJobInterval = 1 * time.Hour
)
// Service coordinates game state, user management, and storage
type Service struct {
games map[string]*game.Game
mu sync.RWMutex
store *storage.Store // nil if persistence disabled
jwtSecret []byte
waiter *WaitRegistry // Long-polling notification registry
games map[string]*game.Game
mu sync.RWMutex
store *storage.Store
jwtSecret []byte
waiter *WaitRegistry
computerGames atomic.Int32 // Active games with computer players
}
// New creates a new service instance with optional storage
@ -47,12 +58,56 @@ func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context
return s.waiter.RegisterWait(gameID, moveCount, ctx)
}
// CanCreateComputerGame checks if a new computer game can be created
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 {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
}
return g.ClaimSlot(color, userID)
}
// 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 {
// Collect all errors
var errs []error
// Shutdown wait registry
if err := s.waiter.Shutdown(timeout); err != nil {
errs = append(errs, fmt.Errorf("wait registry: %w", err))
}
@ -60,10 +115,8 @@ func (s *Service) Shutdown(timeout time.Duration) error {
s.mu.Lock()
defer s.mu.Unlock()
// Clear all games
s.games = make(map[string]*game.Game)
// Close storage if enabled
if s.store != nil {
if err := s.store.Close(); err != nil {
errs = append(errs, fmt.Errorf("storage: %w", err))
@ -71,4 +124,40 @@ func (s *Service) Shutdown(timeout time.Duration) error {
}
return errors.Join(errs...)
}
// RunCleanupJob runs periodic cleanup of expired users and sessions
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.cleanupExpired()
}
}
}
func (s *Service) cleanupExpired() {
if s.store == nil {
return
}
// Cleanup expired temp users
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
// Log but don't fail
fmt.Printf("cleanup: failed to delete expired users: %v\n", err)
} else if deleted > 0 {
fmt.Printf("cleanup: deleted %d expired temp users\n", deleted)
}
// Cleanup expired sessions
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
fmt.Printf("cleanup: failed to delete expired sessions: %v\n", err)
} else if deleted > 0 {
fmt.Printf("cleanup: deleted %d expired sessions\n", deleted)
}
}

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/service/user.go
package service
import (
@ -14,25 +13,54 @@ import (
// User represents a registered user account
type User struct {
UserID string
Username string
Email string
CreatedAt time.Time
UserID string
Username string
Email string
AccountType string
CreatedAt time.Time
ExpiresAt *time.Time
}
// CreateUser creates new user with transactional consistency
func (s *Service) CreateUser(username, email, password string) (*User, error) {
// CreateUser creates new user with registration limits enforcement
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
if s.store == nil {
return nil, fmt.Errorf("storage disabled")
}
// Check registration limits
total, permCount, _, err := s.store.GetUserCounts()
if err != nil {
return nil, fmt.Errorf("failed to check user limits: %w", err)
}
// Determine account type
accountType := "temp"
var expiresAt *time.Time
if permanent {
if permCount >= PermanentSlots {
return nil, fmt.Errorf("permanent user slots full (%d/%d)", permCount, PermanentSlots)
}
accountType = "permanent"
} else {
expiry := time.Now().UTC().Add(TempUserTTL)
expiresAt = &expiry
}
// Handle capacity - remove oldest temp user if at max
if total >= MaxUsers {
if err := s.removeOldestTempUser(); err != nil {
return nil, fmt.Errorf("at capacity and cannot make room: %w", err)
}
}
// Hash password
passwordHash, err := auth.HashPassword(password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Generate guaranteed unique user ID with proper collision handling
// Generate unique user ID
userID, err := s.generateUniqueUserID()
if err != nil {
return nil, fmt.Errorf("failed to generate unique ID: %w", err)
@ -40,19 +68,22 @@ func (s *Service) CreateUser(username, email, password string) (*User, error) {
// Create user record
user := &User{
UserID: userID,
Username: username,
Email: email,
CreatedAt: time.Now().UTC(),
UserID: userID,
Username: username,
Email: email,
AccountType: accountType,
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
}
// Use transactional storage method
record := storage.UserRecord{
UserID: userID,
Username: username,
Email: email,
Username: strings.ToLower(username),
Email: strings.ToLower(email),
PasswordHash: passwordHash,
AccountType: accountType,
CreatedAt: user.CreatedAt,
ExpiresAt: expiresAt,
}
if err = s.store.CreateUser(record); err != nil {
@ -62,11 +93,28 @@ func (s *Service) CreateUser(username, email, password string) (*User, error) {
return user, nil
}
// AuthenticateUser verifies user credentials and returns user information
// AuthenticateUser verifies user credentials and returns user information
func (s *Service) AuthenticateUser(identifier, password string) (*User, error) {
// removeOldestTempUser removes the oldest temporary user to make room
func (s *Service) removeOldestTempUser() error {
oldest, err := s.store.GetOldestTempUser()
if err != nil {
return fmt.Errorf("no temp users to remove: %w", err)
}
// Delete their session first
_ = s.store.DeleteSessionByUserID(oldest.UserID)
// Delete the user
if err := s.store.DeleteUserByID(oldest.UserID); err != nil {
return fmt.Errorf("failed to remove oldest user: %w", err)
}
return nil
}
// AuthenticateUser verifies credentials and creates a new session
func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
if s.store == nil {
return nil, fmt.Errorf("storage disabled")
return nil, "", fmt.Errorf("storage disabled")
}
var userRecord *storage.UserRecord
@ -80,36 +128,62 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, error) {
}
if err != nil {
// Always hash to prevent timing attacks
auth.HashPassword(password)
return nil, fmt.Errorf("invalid credentials")
auth.HashPassword(password) // Timing attack prevention
return nil, "", fmt.Errorf("invalid credentials")
}
// Verify password
if err := auth.VerifyPassword(password, userRecord.PasswordHash); err != nil {
return nil, fmt.Errorf("invalid credentials")
return nil, "", fmt.Errorf("invalid credentials")
}
return &User{
// Check if temp user expired
if userRecord.AccountType == "temp" && userRecord.ExpiresAt != nil {
if time.Now().UTC().After(*userRecord.ExpiresAt) {
return nil, "", fmt.Errorf("account expired")
}
}
// Create new session (invalidates any existing session)
sessionID := uuid.New().String()
sessionRecord := storage.SessionRecord{
SessionID: sessionID,
UserID: userRecord.UserID,
Username: userRecord.Username,
Email: userRecord.Email,
CreatedAt: userRecord.CreatedAt,
}, nil
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(SessionTTL),
}
if err := s.store.CreateSession(sessionRecord); err != nil {
return nil, "", fmt.Errorf("failed to create session: %w", err)
}
// Update last login
_ = s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC())
return &User{
UserID: userRecord.UserID,
Username: userRecord.Username,
Email: userRecord.Email,
AccountType: userRecord.AccountType,
CreatedAt: userRecord.CreatedAt,
ExpiresAt: userRecord.ExpiresAt,
}, sessionID, nil
}
// UpdateLastLogin updates the last login timestamp for a user
func (s *Service) UpdateLastLogin(userID string) error {
// ValidateSession checks if a session is valid
func (s *Service) ValidateSession(sessionID string) (bool, error) {
if s.store == nil {
return false, fmt.Errorf("storage disabled")
}
return s.store.IsSessionValid(sessionID)
}
// InvalidateSession removes a session (logout)
func (s *Service) InvalidateSession(sessionID string) error {
if s.store == nil {
return fmt.Errorf("storage disabled")
}
err := s.store.UpdateUserLastLoginSync(userID, time.Now().UTC())
if err != nil {
return fmt.Errorf("failed to update last login time for user %s: %w\n", userID, err)
}
return nil
return s.store.DeleteSession(sessionID)
}
// GetUserByID retrieves user information by user ID
@ -124,31 +198,47 @@ func (s *Service) GetUserByID(userID string) (*User, error) {
}
return &User{
UserID: userRecord.UserID,
Username: userRecord.Username,
Email: userRecord.Email,
CreatedAt: userRecord.CreatedAt,
UserID: userRecord.UserID,
Username: userRecord.Username,
Email: userRecord.Email,
AccountType: userRecord.AccountType,
CreatedAt: userRecord.CreatedAt,
ExpiresAt: userRecord.ExpiresAt,
}, nil
}
// GenerateUserToken creates a JWT token for the specified user
func (s *Service) GenerateUserToken(userID string) (string, error) {
// GenerateUserToken creates a JWT token for the specified user with session ID
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,
"username": user.Username,
"email": user.Email,
"session_id": sessionID,
}
return auth.GenerateHS256Token(s.jwtSecret, userID, claims, 7*24*time.Hour)
return auth.GenerateHS256Token(s.jwtSecret, userID, claims, SessionTTL)
}
// ValidateToken verifies JWT token and returns user ID with claims
// ValidateToken verifies JWT token and session validity
func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
return auth.ValidateHS256Token(s.jwtSecret, token)
userID, claims, err := auth.ValidateHS256Token(s.jwtSecret, token)
if err != nil {
return "", nil, err
}
// Validate session is still active
if sessionID, ok := claims["session_id"].(string); ok && s.store != nil {
valid, err := s.store.IsSessionValid(sessionID)
if err != nil || !valid {
return "", nil, fmt.Errorf("session invalidated")
}
}
return userID, claims, nil
}
// generateUniqueUserID creates a unique user ID with collision detection
@ -157,19 +247,32 @@ func (s *Service) generateUniqueUserID() (string, error) {
for i := 0; i < maxAttempts; i++ {
id := uuid.New().String()
// Check for collision
if _, err := s.store.GetUserByID(id); err != nil {
// Error means not found, ID is unique
return id, nil
}
// Collision detected, try again
if i == maxAttempts-1 {
// After max attempts, fail and don't risk collision
return "", fmt.Errorf("failed to generate unique ID after %d attempts", maxAttempts)
}
}
return "", fmt.Errorf("failed to generate unique user ID")
}
// CreateUserSession creates a session for a user without re-authenticating
// Used after registration to avoid redundant password hashing
func (s *Service) CreateUserSession(userID string) (string, error) {
if s.store == nil {
return "", fmt.Errorf("storage disabled")
}
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("failed to create session: %w", err)
}
return sessionID, nil
}

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/service/waiter.go
package service
import (

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/storage/game.go
package storage
import (

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/storage/schema.go
package storage
import "time"
@ -9,10 +8,20 @@ type UserRecord struct {
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"`
}
// 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"`
}
// GameRecord represents a row in the games table
type GameRecord struct {
GameID string `db:"game_id"`
@ -35,7 +44,7 @@ type MoveRecord struct {
MoveNumber int `db:"move_number"`
MoveUCI string `db:"move_uci"`
FENAfterMove string `db:"fen_after_move"`
PlayerColor string `db:"player_color"` // "w" or "b"
PlayerColor string `db:"player_color"`
MoveTimeUTC time.Time `db:"move_time_utc"`
}
@ -46,14 +55,29 @@ CREATE TABLE IF NOT EXISTS users (
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
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_account_type ON users(account_type);
CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users(expires_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email) WHERE email IS NOT NULL AND email != '';
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 INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS games (
game_id TEXT PRIMARY KEY,
initial_fen TEXT NOT NULL,

View File

@ -0,0 +1,92 @@
package storage
import (
"fmt"
"time"
)
// CreateSession creates or replaces the session for a user (single session per user)
func (s *Store) CreateSession(record SessionRecord) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Delete any existing session for this user
deleteQuery := `DELETE FROM sessions WHERE user_id = ?`
if _, err := tx.Exec(deleteQuery, record.UserID); err != nil {
return fmt.Errorf("failed to delete existing session: %w", err)
}
// Insert new session
insertQuery := `INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`
if _, err := tx.Exec(insertQuery, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil {
return fmt.Errorf("failed to create session: %w", err)
}
return tx.Commit()
}
// GetSession retrieves a session by ID
func (s *Store) GetSession(sessionID string) (*SessionRecord, error) {
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,
)
if err != nil {
return nil, err
}
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
func (s *Store) DeleteSession(sessionID string) error {
query := `DELETE FROM sessions WHERE session_id = ?`
_, err := s.db.Exec(query, sessionID)
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)
return err
}
// 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())
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// IsSessionValid checks if a session exists and is not expired
func (s *Store) IsSessionValid(sessionID string) (bool, error) {
var count int
query := `SELECT COUNT(*) FROM sessions WHERE session_id = ? AND expires_at > ?`
err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/storage/storage.go
package storage
import (

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/storage/user.go
package storage
import (
@ -8,6 +7,64 @@ import (
"time"
)
// UserLimits defines registration constraints
type UserLimits struct {
MaxUsers int
PermanentSlots int
TempTTL time.Duration
}
// DefaultUserLimits returns default POC limits
func DefaultUserLimits() UserLimits {
return UserLimits{
MaxUsers: 100,
PermanentSlots: 10,
TempTTL: 24 * time.Hour,
}
}
// GetUserCounts returns current user counts by type
func (s *Store) GetUserCounts() (total, permanent, temp int, err error) {
query := `SELECT
COUNT(*) as total,
SUM(CASE WHEN account_type = 'permanent' THEN 1 ELSE 0 END) as permanent,
SUM(CASE WHEN account_type = 'temp' THEN 1 ELSE 0 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
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, &user.Email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
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 < ?`
result, err := s.db.Exec(query, time.Now().UTC())
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// CreateUser creates user with transaction isolation to prevent race conditions
func (s *Store) CreateUser(record UserRecord) error {
tx, err := s.db.Begin()
@ -27,12 +84,12 @@ func (s *Store) CreateUser(record UserRecord) error {
// Insert user
query := `INSERT INTO users (
user_id, username, email, password_hash, created_at
) VALUES (?, ?, ?, ?, ?)`
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.CreatedAt,
record.PasswordHash, record.AccountType, record.CreatedAt, record.ExpiresAt,
)
if err != nil {
return err
@ -41,6 +98,20 @@ func (s *Store) CreateUser(record UserRecord) error {
return tx.Commit()
}
// 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)
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
@ -82,7 +153,7 @@ func (s *Store) UpdateUserUsername(userID string, username string) error {
// GetAllUsers retrieves all users
func (s *Store) GetAllUsers() ([]UserRecord, error) {
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users ORDER BY created_at DESC`
rows, err := s.db.Query(query)
@ -96,7 +167,8 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
var user UserRecord
err := rows.Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
@ -110,24 +182,23 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
// 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
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
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, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
@ -138,12 +209,13 @@ func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
// GetUserByEmail retrieves user by email with case-insensitive matching
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users WHERE email = ? COLLATE NOCASE`
err := s.db.QueryRow(query, email).Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
@ -154,12 +226,13 @@ func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
// GetUserByID retrieves user by unique user ID
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users WHERE user_id = ?`
err := s.db.QueryRow(query, userID).Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
@ -167,7 +240,7 @@ func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
return &user, nil
}
// DeleteUser removes a user from the database
// DeleteUser removes a user from the database (async)
func (s *Store) DeleteUser(userID string) error {
if !s.healthStatus.Load() {
return nil

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/webserver/web/app.js
// Game state management
let gameState = {
gameId: null,
@ -681,6 +680,14 @@ function handleApiError(action, error, response = null) {
statusMessage = 'Invalid Request';
}
break;
case 403:
serverStatus = 'healthy';
if (action === 'move' || action === 'trigger computer move') {
statusMessage = 'Slot Claimed';
} else {
statusMessage = 'Not Authorized';
}
break;
case 404:
serverStatus = 'healthy'; // Server is fine, game doesn't exist
statusMessage = 'Game Not Found';

View File

@ -1,4 +1,3 @@
<!-- FILE: lixenwraith/chess/internal/server/webserver/web/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>

View File

@ -1,4 +1,3 @@
/* FILE: lixenwraith/chess/internal/server/webserver/web/style.css */
* {
margin: 0;
padding: 0;

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/webserver/server.go
package webserver
import (