- Embedded NATS server with JetStream (sync_interval=always per Jepsen 2025) - AUDIT and STATE JetStream streams for tournament event durability - NATS publisher with UUID validation to prevent subject injection - WebSocket hub with JWT auth (query param), tournament-scoped broadcasting - Origin validation and slow-consumer message dropping - chi HTTP router with middleware (logger, recoverer, request ID, CORS, body limits) - Server timeouts: ReadHeader 10s, Read 30s, Write 60s, Idle 120s, MaxHeader 1MB - MaxBytesReader middleware for request body limits (1MB default) - JWT auth middleware with HMAC-SHA256 validation - Role-based access control (admin > floor > viewer) - Health endpoint reporting all subsystem status (DB, NATS, WebSocket) - SvelteKit SPA served via go:embed with fallback routing - Signal-driven graceful shutdown in reverse startup order - 9 integration tests covering all verification criteria Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
138 lines
4 KiB
Go
138 lines
4 KiB
Go
// Command leaf is the Felt tournament engine binary. It starts all embedded
|
|
// infrastructure (LibSQL, NATS JetStream, WebSocket hub) and serves the
|
|
// SvelteKit SPA over HTTP.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"flag"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
feltnats "github.com/felt-app/felt/internal/nats"
|
|
"github.com/felt-app/felt/internal/server"
|
|
"github.com/felt-app/felt/internal/server/middleware"
|
|
"github.com/felt-app/felt/internal/server/ws"
|
|
"github.com/felt-app/felt/internal/store"
|
|
)
|
|
|
|
func main() {
|
|
dataDir := flag.String("data-dir", "./data", "Data directory for database and files")
|
|
addr := flag.String("addr", ":8080", "HTTP listen address")
|
|
devMode := flag.Bool("dev", false, "Enable development mode (permissive CORS, dev seed data)")
|
|
flag.Parse()
|
|
|
|
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
|
log.SetPrefix("felt: ")
|
|
|
|
log.Printf("starting (data-dir=%s, addr=%s, dev=%v)", *dataDir, *addr, *devMode)
|
|
|
|
// Create root context with cancellation
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// ---- 1. LibSQL Database ----
|
|
db, err := store.Open(*dataDir, *devMode)
|
|
if err != nil {
|
|
log.Fatalf("failed to open database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Verify database is working
|
|
var one int
|
|
if err := db.QueryRow("SELECT 1").Scan(&one); err != nil {
|
|
log.Fatalf("database health check failed: %v", err)
|
|
}
|
|
log.Printf("database ready")
|
|
|
|
// ---- 2. Embedded NATS Server ----
|
|
natsServer, err := feltnats.Start(ctx, *dataDir)
|
|
if err != nil {
|
|
log.Fatalf("failed to start NATS: %v", err)
|
|
}
|
|
defer natsServer.Shutdown()
|
|
|
|
// ---- 3. JWT Signing Key ----
|
|
// In production, this should be loaded from a persisted secret.
|
|
// For now, generate a random key on startup (tokens won't survive restart).
|
|
signingKey := generateOrLoadSigningKey(*dataDir)
|
|
|
|
// ---- 4. WebSocket Hub ----
|
|
tokenValidator := func(tokenStr string) (string, string, error) {
|
|
return middleware.ValidateJWT(tokenStr, signingKey)
|
|
}
|
|
|
|
// Tournament validator stub — allows all for now
|
|
// TODO: Implement tournament existence + access check against DB
|
|
tournamentValidator := func(tournamentID string, operatorID string) error {
|
|
return nil // Accept all tournaments for now
|
|
}
|
|
|
|
var allowedOrigins []string
|
|
if *devMode {
|
|
allowedOrigins = nil // InsecureSkipVerify will be used
|
|
} else {
|
|
allowedOrigins = []string{"*"} // Same-origin enforced by browser
|
|
}
|
|
|
|
hub := ws.NewHub(tokenValidator, tournamentValidator, allowedOrigins)
|
|
defer hub.Shutdown()
|
|
|
|
// ---- 5. HTTP Server ----
|
|
srv := server.New(server.Config{
|
|
Addr: *addr,
|
|
SigningKey: signingKey,
|
|
DevMode: *devMode,
|
|
}, db.DB, natsServer.Server(), hub)
|
|
|
|
// Start HTTP server in goroutine
|
|
go func() {
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("HTTP server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
log.Printf("ready (addr=%s, dev=%v)", *addr, *devMode)
|
|
|
|
// ---- Signal Handling ----
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
sig := <-sigCh
|
|
log.Printf("received signal: %s, shutting down...", sig)
|
|
|
|
// Graceful shutdown in reverse startup order
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer shutdownCancel()
|
|
|
|
// 5. HTTP Server
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
log.Printf("HTTP server shutdown error: %v", err)
|
|
}
|
|
|
|
// 4. WebSocket Hub (closed by defer)
|
|
// 3. NATS Server (closed by defer)
|
|
// 2. Database (closed by defer)
|
|
|
|
cancel() // Cancel root context
|
|
|
|
log.Printf("shutdown complete")
|
|
}
|
|
|
|
// generateOrLoadSigningKey generates a random 256-bit signing key.
|
|
// In a future plan, this will be persisted to the data directory.
|
|
func generateOrLoadSigningKey(dataDir string) []byte {
|
|
// TODO: Persist to file in dataDir for key stability across restarts
|
|
_ = dataDir
|
|
key := make([]byte, 32)
|
|
if _, err := rand.Read(key); err != nil {
|
|
log.Fatalf("failed to generate signing key: %v", err)
|
|
}
|
|
log.Printf("JWT signing key generated (ephemeral — will change on restart)")
|
|
return key
|
|
}
|