felt/internal/server/server.go
Mikkel Georgsen 99545bd128 feat(01-05): implement building block CRUD and API routes
- 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>
2026-03-01 03:55:47 +01:00

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)
})
}
}