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