- ChipSetService with full CRUD, duplication, builtin protection - BlindStructure service with level validation and CRUD - PayoutStructure service with bracket/tier nesting and 100% sum validation - BuyinConfig service with rake split validation and all rebuy/addon fields - TournamentTemplate service with FK validation and expanded view - WizardService generates blind structures from high-level inputs - API routes: /chip-sets, /blind-structures, /payout-structures, /buyin-configs, /tournament-templates - All mutations require admin role, reads require floor+ - Wired template routes into server protected group Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
234 lines
6.2 KiB
Go
234 lines
6.2 KiB
Go
// Package server provides the HTTP server for the Felt tournament engine.
|
|
// It configures chi router with middleware, defines route groups, and serves
|
|
// the SvelteKit SPA via go:embed.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimw "github.com/go-chi/chi/v5/middleware"
|
|
|
|
"github.com/felt-app/felt/frontend"
|
|
"github.com/felt-app/felt/internal/auth"
|
|
"github.com/felt-app/felt/internal/clock"
|
|
"github.com/felt-app/felt/internal/server/middleware"
|
|
"github.com/felt-app/felt/internal/server/routes"
|
|
"github.com/felt-app/felt/internal/server/ws"
|
|
natsserver "github.com/nats-io/nats-server/v2/server"
|
|
)
|
|
|
|
// Config holds server configuration.
|
|
type Config struct {
|
|
Addr string
|
|
SigningKey []byte
|
|
DevMode bool
|
|
}
|
|
|
|
// Server wraps the HTTP server with all dependencies.
|
|
type Server struct {
|
|
httpServer *http.Server
|
|
hub *ws.Hub
|
|
db *sql.DB
|
|
nats *natsserver.Server
|
|
authService *auth.AuthService
|
|
clockRegistry *clock.Registry
|
|
}
|
|
|
|
// New creates a new HTTP server with all routes and middleware configured.
|
|
func New(cfg Config, db *sql.DB, nats *natsserver.Server, hub *ws.Hub, authService *auth.AuthService, clockRegistry *clock.Registry) *Server {
|
|
r := chi.NewRouter()
|
|
|
|
// Global middleware
|
|
r.Use(chimw.RequestID)
|
|
r.Use(chimw.RealIP)
|
|
r.Use(chimw.Logger)
|
|
r.Use(chimw.Recoverer)
|
|
|
|
// Request body size limit: 1MB default
|
|
r.Use(middleware.MaxBytesReader(1 << 20)) // 1MB
|
|
|
|
// CORS: permissive for development
|
|
r.Use(corsMiddleware(cfg.DevMode))
|
|
|
|
s := &Server{
|
|
hub: hub,
|
|
db: db,
|
|
nats: nats,
|
|
authService: authService,
|
|
clockRegistry: clockRegistry,
|
|
}
|
|
|
|
// Auth handler
|
|
authHandler := routes.NewAuthHandler(authService)
|
|
|
|
// Clock handler
|
|
clockHandler := routes.NewClockHandler(clockRegistry, db)
|
|
|
|
// API routes
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
// Public endpoints (no auth required)
|
|
r.Get("/health", s.handleHealth)
|
|
|
|
// Auth endpoints (login is public, others require auth)
|
|
r.Post("/auth/login", authHandler.HandleLogin)
|
|
|
|
// Protected endpoints
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.JWTAuth(cfg.SigningKey))
|
|
|
|
// Auth endpoints that require authentication
|
|
r.Get("/auth/me", authHandler.HandleMe)
|
|
r.Post("/auth/logout", authHandler.HandleLogout)
|
|
|
|
// Template building blocks and tournament templates
|
|
templateRoutes := routes.NewTemplateRoutes(db)
|
|
templateRoutes.Register(r)
|
|
|
|
// Clock routes
|
|
clockHandler.RegisterRoutes(r)
|
|
|
|
// Stub endpoints -- return 200 for now
|
|
r.Get("/tournaments", stubHandler("tournaments"))
|
|
r.Get("/players", stubHandler("players"))
|
|
})
|
|
})
|
|
|
|
// WebSocket endpoint
|
|
r.Get("/ws", hub.HandleConnect)
|
|
|
|
// SvelteKit SPA fallback (must be last)
|
|
r.Handle("/*", frontend.Handler())
|
|
|
|
s.httpServer = &http.Server{
|
|
Addr: cfg.Addr,
|
|
Handler: r,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 60 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
MaxHeaderBytes: 1 << 20, // 1MB
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// Handler returns the HTTP handler for use in tests.
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.httpServer.Handler
|
|
}
|
|
|
|
// ListenAndServe starts the HTTP server.
|
|
func (s *Server) ListenAndServe() error {
|
|
log.Printf("server: listening on %s", s.httpServer.Addr)
|
|
return s.httpServer.ListenAndServe()
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the HTTP server.
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
log.Printf("server: shutting down")
|
|
return s.httpServer.Shutdown(ctx)
|
|
}
|
|
|
|
// handleHealth returns the health status of all subsystems.
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
status := map[string]interface{}{
|
|
"status": "ok",
|
|
"subsystems": map[string]interface{}{
|
|
"database": s.checkDatabase(),
|
|
"nats": s.checkNATS(),
|
|
"websocket": map[string]interface{}{
|
|
"status": "ok",
|
|
"clients": s.hub.ClientCount(),
|
|
},
|
|
},
|
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(status)
|
|
}
|
|
|
|
// checkDatabase verifies the database is operational.
|
|
func (s *Server) checkDatabase() map[string]interface{} {
|
|
var result int
|
|
err := s.db.QueryRow("SELECT 1").Scan(&result)
|
|
if err != nil {
|
|
return map[string]interface{}{
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
}
|
|
}
|
|
return map[string]interface{}{
|
|
"status": "ok",
|
|
}
|
|
}
|
|
|
|
// checkNATS verifies the NATS server is operational.
|
|
func (s *Server) checkNATS() map[string]interface{} {
|
|
if s.nats == nil {
|
|
return map[string]interface{}{
|
|
"status": "error",
|
|
"error": "not initialized",
|
|
}
|
|
}
|
|
|
|
varz, err := s.nats.Varz(nil)
|
|
if err != nil {
|
|
return map[string]interface{}{
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"status": "ok",
|
|
"connections": varz.Connections,
|
|
"jetstream": s.nats.JetStreamEnabled(),
|
|
}
|
|
}
|
|
|
|
// stubHandler returns a handler that responds with 200 and a JSON stub.
|
|
func stubHandler(name string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "stub",
|
|
"name": name,
|
|
"message": "Not yet implemented",
|
|
})
|
|
}
|
|
}
|
|
|
|
// corsMiddleware returns CORS middleware. In dev mode, it's permissive.
|
|
func corsMiddleware(devMode bool) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if devMode {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
} else {
|
|
// In production, only allow same-origin
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
}
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-Request-ID")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Max-Age", "300")
|
|
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|