diff --git a/server/.env.example b/server/.env.example index f227049..93e0f89 100644 --- a/server/.env.example +++ b/server/.env.example @@ -27,5 +27,5 @@ X0GP_SEED_RESET=0 # PoC: skip auth, allow multiple clients on the same track. X0GP_DEV_MODE=true -# Optional: override path(s) to dotenv files (colon-separated). -# X0GP_DOTENV=.env:./configs/local.env \ No newline at end of file +# JWT secret (Supabase service role or JWT secret). Required when X0GP_DEV_MODE=false. +X0GP_JWT_SECRET=your-supabase-jwt-secret-here \ No newline at end of file diff --git a/server/CODEBASE.md b/server/CODEBASE.md index 0bb2d0c..4d742fb 100644 --- a/server/CODEBASE.md +++ b/server/CODEBASE.md @@ -2,87 +2,6 @@ This file contains a compiled map of all packages, structures, interfaces, and public functions in the `server` directory. It is optimized for token-efficiency so AI agents and humans can understand the codebase layout without reading all files. -## Package `catalog` (`internal/catalog`) - -Package catalog owns the static configuration entities: tracks and -cars. Both are first-class resources that drivers browse, pick, and -(in later phases) author. - -PoC: in-memory cache backed by a pluggable Store. The runtime uses -postgres.PgStore so tracks / cars survive restarts. The cache is the -source of truth for read paths; the Store is the source of truth for -durability. Mutations go through the Store, then refresh the cache. - -Design constraints come from the user's apartment layout: - - largest room: 4.46 m × 3.19 m (446 × 319 cm) - - car scale: 1/27 (Compact) - - printer: Bambu A1 (220×220×250 mm build volume) - -Tracks are sized to fit a 4.5 × 3.2 m bounding box with a safety -margin, which keeps them physically realizable in the apartment. -Cars are sized to a 1/27 touring envelope (~80 × 35 × 25 mm). - -### Interfaces -- **interface Store** { Load, UpsertTrack, DeleteTrack, UpsertCar, DeleteCar, UpdateCarStats, GetTrackCalendar, CreateTrackCalendar, DeleteTrackCalendar } - -### Structs -- **struct CreateTrackOptions** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline } -- **struct UpdateTrackOptions** { Name, Description, Visibility, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline } -- **struct CreateCarOptions** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, AvatarURL, Active, DeviceID } -- **struct UpdateCarOptions** { Name, Visibility, LengthMm, WidthMm, HeightMm, WeightG, TopSpeedMs, ColorHex, Active, Chassis, Motor, Battery, Drive, DeviceID } -- **struct Waypoint** { X, Y, HeadingRad, SpeedMs, Curvature } -- **struct Bounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM } -- **struct TrackMeta** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Bounds, Surface, Tags, Centerline, BestLapMs, BestLapHolder, CreatedMs, UpdatedMs } -- **struct TrackCalendarEntry** { ID, TrackID, StartAt, EndAt } -- **struct MotorSpec** { Kind, Class, KV, PowerW } -- **struct BatterySpec** { VoltageV, CapacityMah, Cells, Chemistry } -- **struct ChassisSpec** { Model, Material, Printed, WheelbaseMm, TrackMm } -- **struct CarMeta** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, DeviceID, TopSpeedMs, ColorHex, AvatarURL, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs, CreatedMs, UpdatedMs } -- **struct Snapshot** { GeneratedMs, Version, Tracks, Cars } -- **struct Stats** { TracksTotal, TracksSystem, TracksPublic, TracksPrivate, CarsTotal, CarsSystem, CarsPublic, CarsPrivate, CarsActive } -- **struct Event** { Kind, TrackID, CarID, Snapshot } -- **struct Service** - -### Functions -- func NewService(context.Context, Store) (*Service, error) - -### Methods -- func (CreateTrackOptions) Validate() error -- func (*Service) CreateTrack(context.Context, CreateTrackOptions) (TrackMeta, error) -- func (*Service) UpdateTrack(context.Context, string, UpdateTrackOptions) (TrackMeta, error) -- func (*Service) DeleteTrack(context.Context, string) error -- func (CreateCarOptions) Validate() error -- func (*Service) CreateCar(context.Context, CreateCarOptions) (CarMeta, error) -- func (*Service) UpdateCar(context.Context, string, UpdateCarOptions) (CarMeta, error) -- func (*Service) DeleteCar(context.Context, string) error -- func (*Service) SetCarStats(context.Context, string, func) error -- func (*Service) ListTracksByVisibility(Visibility) []TrackMeta -- func (*Service) ListCarsByVisibility(Visibility) []CarMeta -- func (*Service) ListCarsByOwner(string) []CarMeta -- func (*Service) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error) -- func (*Service) CreateTrackCalendar(context.Context, string, time.Time, time.Time) (TrackCalendarEntry, error) -- func (*Service) DeleteTrackCalendar(context.Context, int) error -- func (*memoryStore) Load(context.Context) ([]TrackMeta, []CarMeta, error) -- func (*memoryStore) UpsertTrack(context.Context, TrackMeta) error -- func (*memoryStore) DeleteTrack(context.Context, string) error -- func (*memoryStore) UpsertCar(context.Context, CarMeta) error -- func (*memoryStore) DeleteCar(context.Context, string) error -- func (*memoryStore) UpdateCarStats(context.Context, string, func) error -- func (*memoryStore) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error) -- func (*memoryStore) CreateTrackCalendar(context.Context, TrackCalendarEntry) (TrackCalendarEntry, error) -- func (*memoryStore) DeleteTrackCalendar(context.Context, int) error -- func (*Service) Subscribe() (?, func) -- func (*Service) Stop() -- func (*Service) Done() ? -- func (*Service) Snapshot() Snapshot -- func (*Service) Stats() Stats -- func (*Service) ListTracks() []TrackMeta -- func (*Service) ListCars() []CarMeta -- func (*Service) GetTrack(string) (TrackMeta, bool) -- func (*Service) GetCar(string) (CarMeta, bool) - ---- - ## Package `clans` (`internal/clans`) Package clans owns persistence and business logic for clans. @@ -134,70 +53,111 @@ Existing process env vars take precedence over .env so deployments can override .env without modifying the file. ### Structs -- **struct Config** { HTTPAddr, TickRate, TickInterval, LogLevel, MaxClients, SnapshotRate, DevMode, DatabaseURL, UDPVideoAddr, ESPCmdPort } +- **struct Config** { HTTPAddr, TickRate, TickInterval, LogLevel, MaxClients, SnapshotRate, DevMode, DatabaseURL, JWTSecret, UDPVideoAddr, ESPCmdPort } ### Functions - func Load() (*Config, error) --- -## Package `postgres` (`internal/storage/postgres`) +## Package `lobby` (`internal/lobby`) -Package postgres owns the persistence layer of the catalog. +Package lobby owns the "outside any race" state of the server: which +races are open, which drivers are connected but idle, who is the host +of each race. -It exposes a *pgxpool.Pool plus a small set of repository helpers used by -internal/catalog. Migrations are applied at startup from the SQL files in -the migrations/ subdirectory. +The lobby is the single source of truth for matchmaking and for +rendering the "what races are available right now" list. It does NOT +own per-race physics — that's the Engine (internal/control). + +All operations are thread-safe. Mutations broadcast a snapshot event +to subscribers (see Service.Subscribe). + +### Interfaces +- **interface Persistence** { UpsertRace, DeleteRace, AddDriver, RemoveDriver, SetRaceStatus, UpsertDriver, DeleteDriver } ### Structs -- **struct PgStore** -- **struct Config** { URL, Host, Port, User, Password, Database, SSLMode, MaxConns, MinConns, ConnectTimeout } +- **struct CreateRaceOptions** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS } +- **struct RaceMeta** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, DriverIDs, Status, CreatedMs, StartedMs } +- **struct DriverMeta** { ID, Name, Nickname, AvatarURL, ClanID, ClanTag, Status, RaceID, ConnectedMs, LastSeenMs, Country, AvatarHue, CarID } +- **struct Snapshot** { GeneratedMs, Races, Drivers, Version } +- **struct Stats** { RacesTotal, RacesOpen, RacesActive, DriversIdle, DriversTotal } +- **struct Service** ### Functions -- func NewPgStore(*pgxpool.Pool) *PgStore -- func Open(context.Context, Config) (*pgxpool.Pool, error) -- func Migrate(context.Context, *pgxpool.Pool) error +- func NewService() *Service ### Methods -- func (*PgStore) Load(context.Context) ([]catalog.TrackMeta, []catalog.CarMeta, error) -- func (*PgStore) UpsertTrack(context.Context, catalog.TrackMeta) error -- func (*PgStore) DeleteTrack(context.Context, string) error -- func (*PgStore) UpsertCar(context.Context, catalog.CarMeta) error -- func (*PgStore) DeleteCar(context.Context, string) error -- func (*PgStore) UpdateCarStats(context.Context, string, func) error -- func (*PgStore) GetTrackCalendar(context.Context) ([]catalog.TrackCalendarEntry, error) -- func (*PgStore) CreateTrackCalendar(context.Context, catalog.TrackCalendarEntry) (catalog.TrackCalendarEntry, error) -- func (*PgStore) DeleteTrackCalendar(context.Context, int) error -- func (Config) DSN() (string, error) +- func (*Service) CreateRace(CreateRaceOptions) (RaceMeta, error) +- func (*Service) AddRace(RaceMeta) +- func (*Service) AddDriver(string, string, string) (DriverMeta, error) +- func (*Service) RemoveDriver(string) +- func (*Service) Heartbeat(string) +- func (*Service) SetDriverProfile(string, string, string, string, string) +- func (*Service) AddDriverToRace(string, string) error +- func (*Service) RemoveDriverFromRace(string) +- func (*Service) SetRaceStatus(string, RaceStatus) error +- func (*Service) DeleteRace(string) error +- func (*Service) QuickPlay(string, int) (RaceMeta, error) +- func (*Service) GetRace(string) (RaceMeta, error) +- func (*Service) GetDriver(string) (DriverMeta, error) +- func (*Service) SelectCar(string, *string) error +- func (*Service) ListRaces() []RaceMeta +- func (*Service) ListDrivers() []DriverMeta +- func (*Service) SetPersistence(Persistence) +- func (*Service) SetRaceLifecycleHook(func, func) +- func (*Service) Subscribe() (?, func) +- func (*Service) Stop() +- func (*Service) Done() ? +- func (*Service) Snapshot() Snapshot +- func (*Service) Stats() Stats --- -## Package `main` (`scripts/genseed-json`) +## Package `main` (`cmd/poc-server`) -Package main contains a small helper that prints the default seeds -(5 F1 tracks + 5 F1 cars) as JSON to stdout. It is run as: +HTTP handlers for the catalog (tracks + cars). - go run ./scripts/genseed-json > seeds.json +All HTTP routes live under /api/*: + GET /api/tracks list tracks (optional ?scope=system|public|private&tag=) + GET /api/tracks/{id} get a single track + POST /api/tracks create a track + PUT /api/tracks/{id} update a track + DELETE /api/tracks/{id} delete a track + GET /api/cars list cars (optional ?scope=...&owner_id=...) + GET /api/cars/{id} get a single car + POST /api/cars create a car + PUT /api/cars/{id} update a car + DELETE /api/cars/{id} delete a car + GET /api/catalog combined snapshot + summary -which is then fed into scripts/genseed to regenerate the SQL. - ---- - -## Package `main` (`scripts/graphify`) - -Package main is the graphify utility. It walks the codebase, parses Go AST, -and outputs a highly compact symbol summary to server/CODEBASE.md. -This is used for token savings and helping developers/AI agents quickly map out imports and structures. +WebSocket access is wired up directly in main.go's readPump switch. ### Structs -- **struct PackageSummary** { Name, Path, Doc, Structs, Interfaces, Functions, Methods } +- **struct Hub** +- **struct UDPVideoService** +- **struct WebRTCService** ---- +### Functions +- func NewHub(*slog.Logger) *Hub +- func NewUDPVideoService(*slog.Logger, string, int) *UDPVideoService +- func NewWebRTCService(*slog.Logger, *control.Engine, *lobby.Service, *UDPVideoService, *catalog.Service) *WebRTCService -## Package `main` (`.`) - -Smoke test: ws client connects, exchanges a few messages, exits. -Run: go run ./smoke_test.go +### Methods +- func (*catalogBroadcaster) Run() +- func (*catalogBroadcaster) Stop() +- func (*Hub) BroadcastSnapshot(transport.RaceSnapshot) +- func (*statusRecorder) WriteHeader(int) +- func (*statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) +- func (*UDPVideoService) Start(context.Context, *sync.WaitGroup) +- func (*UDPVideoService) SendControlPacket(uint8, []byte) error +- func (*UDPVideoService) SendCommand(uint8, string) error +- func (*UDPVideoService) StreamHandler() http.HandlerFunc +- func (*UDPVideoService) ControlHandler() http.HandlerFunc +- func (*WebRTCService) Start(context.Context, *sync.WaitGroup) +- func (*WebRTCService) ConnectHandler() http.HandlerFunc +- func (*WebRTCService) CloseSession(string) +- func (*WebRTCService) CloseAll() --- @@ -358,151 +318,34 @@ The package composes two storage backends: --- -## Package `seed` (`internal/races/seed`) +## Package `postgres` (`internal/storage/postgres`) + +Package postgres owns the persistence layer of the catalog. + +It exposes a *pgxpool.Pool plus a small set of repository helpers used by +internal/catalog. Migrations are applied at startup from the SQL files in +the migrations/ subdirectory. ### Structs -- **struct DefaultCounts** { Finished, Live, Plans } -- **struct Options** { Counts, Seed, Reset, Now } -- **struct Summary** { FinishedInserted, LiveCreated, PlansInserted, QueueInserted, Duration } -- **struct Runner** - -### Functions -- func NewRunner(*races.PgStore, *lobby.Service, *races.LiveStore, *clans.PgStore, *drivers.PgStore) *Runner - -### Methods -- func (*Runner) Run(context.Context, Options) (Summary, error) - ---- - -## Package `stats` (`internal/stats`) - -Package stats collects and exposes server and per-driver metrics. - -PoC: in-memory only, atomic counters where possible, mutex around -driver and race records. The data lives for the lifetime of the -process. A future storage layer will swap this for Postgres/Redis. - -Public surface is split into: - - lifecycle hooks (Connect/Disconnect/Race*/Lap*/Input*/RTT) — - called from cmd/poc-server and from internal/control and lobby - - Snapshot() — full server snapshot for /stats/detailed - - DriverProfile(id) — per-driver summary - - RecentLaps(n) — last N laps across all races - -### Structs -- **struct ServerStats** { StartedMs, UptimeMs, CurrentClients, PeakClients, TotalConnections, TotalDisconnects, TotalRacesCreated, TotalRacesStarted, TotalRacesFinished, TotalLapsRecorded, TotalInputsRecv, TotalSnapshotsSent, SnapshotsDropped, AvgRTTMs, RecentRTTs } -- **struct DriverProfile** { ID, Name, TotalRacesStarted, TotalRacesFinished, TotalLaps, BestLapMs, LastLapMs, TotalDistanceM, TotalPlaytimeMs, Wins, Podiums, LastSeenMs } -- **struct LapRecord** { RaceID, DriverID, LapMs, Sector1Ms, Sector2Ms, Sector3Ms, Position, TSMs } -- **struct RaceResult** { RaceID, DriverID, FinishedAtMs, Position, TotalLaps, BestLapMs, TotalTimeMs, DNF } -- **struct Collector** - -### Functions -- func NewCollector() *Collector - -### Methods -- func (*Collector) Start() -- func (*Collector) OnConnect(string, string) -- func (*Collector) OnDisconnect(string, int64) -- func (*Collector) OnRaceCreated() -- func (*Collector) OnRaceStarted([]string) -- func (*Collector) OnRaceFinished(RaceResult) -- func (*Collector) OnLapRecorded(LapRecord) -- func (*Collector) OnInputProcessed() -- func (*Collector) OnSnapshotSent(uint64, uint64) -- func (*Collector) OnRTT(int64) -- func (*Collector) OnDistance(string, float64) -- func (*Collector) Server() ServerStats -- func (*Collector) Driver(string, string) DriverProfile -- func (*Collector) Drivers() []DriverProfile -- func (*Collector) RecentLaps(int) []LapRecord -- func (*Collector) RecentResults(int) []RaceResult - ---- - -## Package `main` (`cmd/poc-server`) - -HTTP handlers for the catalog (tracks + cars). - -All HTTP routes live under /api/*: - GET /api/tracks list tracks (optional ?scope=system|public|private&tag=) - GET /api/tracks/{id} get a single track - POST /api/tracks create a track - PUT /api/tracks/{id} update a track - DELETE /api/tracks/{id} delete a track - GET /api/cars list cars (optional ?scope=...&owner_id=...) - GET /api/cars/{id} get a single car - POST /api/cars create a car - PUT /api/cars/{id} update a car - DELETE /api/cars/{id} delete a car - GET /api/catalog combined snapshot + summary - -WebSocket access is wired up directly in main.go's readPump switch. - -### Structs -- **struct Hub** -- **struct UDPVideoService** -- **struct WebRTCService** - -### Functions -- func NewHub(*slog.Logger) *Hub -- func NewUDPVideoService(*slog.Logger, string, int) *UDPVideoService -- func NewWebRTCService(*slog.Logger, *control.Engine, *lobby.Service, *UDPVideoService, *catalog.Service) *WebRTCService - -### Methods -- func (*catalogBroadcaster) Run() -- func (*catalogBroadcaster) Stop() -- func (*Hub) BroadcastSnapshot(transport.RaceSnapshot) -- func (*statusRecorder) WriteHeader(int) -- func (*statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) -- func (*UDPVideoService) Start(context.Context, *sync.WaitGroup) -- func (*UDPVideoService) SendControlPacket(uint8, []byte) error -- func (*UDPVideoService) SendCommand(uint8, string) error -- func (*UDPVideoService) StreamHandler() http.HandlerFunc -- func (*UDPVideoService) ControlHandler() http.HandlerFunc -- func (*WebRTCService) Start(context.Context, *sync.WaitGroup) -- func (*WebRTCService) ConnectHandler() http.HandlerFunc -- func (*WebRTCService) CloseSession(string) -- func (*WebRTCService) CloseAll() - ---- - -## Package `drivers` (`internal/drivers`) - -Package drivers owns persistence and business logic for drivers -(racers). Each driver has a unique 3-letter nickname (A-Z, -uppercase, exactly 3 chars), a name, an optional avatar URL and an -optional clan reference. - -### Interfaces -- **interface Store** { Exec, Create, Get, GetByNickname, List, Delete, Update, Count } - -### Structs -- **struct Service** -- **struct CreateInput** { ID, Nickname, Name, AvatarURL, ClanID } -- **struct UpdateInput** { Name, AvatarURL, ClanID } -- **struct Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs } - **struct PgStore** +- **struct Config** { URL, Host, Port, User, Password, Database, SSLMode, MaxConns, MinConns, ConnectTimeout } ### Functions -- func NewService(Store) *Service -- func IsValidNickname(string) bool - func NewPgStore(*pgxpool.Pool) *PgStore +- func Open(context.Context, Config) (*pgxpool.Pool, error) +- func Migrate(context.Context, *pgxpool.Pool) error ### Methods -- func (*Service) Create(context.Context, CreateInput) (Driver, error) -- func (*Service) Get(context.Context, string) (Driver, error) -- func (*Service) GetByNickname(context.Context, string) (Driver, error) -- func (*Service) List(context.Context, int, int, string) ([]Driver, error) -- func (*Service) Update(context.Context, string, UpdateInput) (Driver, error) -- func (*Service) Delete(context.Context, string) error -- func (*PgStore) Exec(context.Context, string) (int64, error) -- func (*PgStore) Create(context.Context, Driver) error -- func (*PgStore) Get(context.Context, string) (Driver, error) -- func (*PgStore) GetByNickname(context.Context, string) (Driver, error) -- func (*PgStore) List(context.Context, int, int, string) ([]Driver, error) -- func (*PgStore) Update(context.Context, Driver) error -- func (*PgStore) Delete(context.Context, string) error -- func (*PgStore) Count(context.Context) (int, error) +- func (*PgStore) Load(context.Context) ([]catalog.TrackMeta, []catalog.CarMeta, error) +- func (*PgStore) UpsertTrack(context.Context, catalog.TrackMeta) error +- func (*PgStore) DeleteTrack(context.Context, string) error +- func (*PgStore) UpsertCar(context.Context, catalog.CarMeta) error +- func (*PgStore) DeleteCar(context.Context, string) error +- func (*PgStore) UpdateCarStats(context.Context, string, func) error +- func (*PgStore) GetTrackCalendar(context.Context) ([]catalog.TrackCalendarEntry, error) +- func (*PgStore) CreateTrackCalendar(context.Context, catalog.TrackCalendarEntry) (catalog.TrackCalendarEntry, error) +- func (*PgStore) DeleteTrackCalendar(context.Context, int) error +- func (Config) DSN() (string, error) --- @@ -612,6 +455,271 @@ TODO PoC: replace JSON with protobuf-generated structs (see --- +## Package `auth` (`internal/auth`) + +Package auth handles Supabase JWT validation and HTTP middleware. + +### Structs +- **struct Claims** { DriverID, Email } + +### Functions +- func ValidateJWT(string, string) (Claims, error) +- func Middleware(string, bool) func +- func DriverIDFromContext(context.Context) (string, bool) +- func EmailFromContext(context.Context) (string, bool) + +--- + +## Package `drivers` (`internal/drivers`) + +Package drivers owns persistence and business logic for drivers +(racers). Each driver has a unique 3-letter nickname (A-Z, +uppercase, exactly 3 chars), a name, an optional avatar URL and an +optional clan reference. + +### Interfaces +- **interface Store** { Exec, Create, Get, GetByNickname, List, Delete, Update, Count } + +### Structs +- **struct Service** +- **struct CreateInput** { ID, Nickname, Name, AvatarURL, ClanID } +- **struct UpdateInput** { Name, AvatarURL, ClanID } +- **struct Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs } +- **struct PgStore** + +### Functions +- func NewService(Store) *Service +- func IsValidNickname(string) bool +- func NewPgStore(*pgxpool.Pool) *PgStore + +### Methods +- func (*Service) Create(context.Context, CreateInput) (Driver, error) +- func (*Service) Get(context.Context, string) (Driver, error) +- func (*Service) GetByNickname(context.Context, string) (Driver, error) +- func (*Service) List(context.Context, int, int, string) ([]Driver, error) +- func (*Service) Update(context.Context, string, UpdateInput) (Driver, error) +- func (*Service) Delete(context.Context, string) error +- func (*PgStore) Exec(context.Context, string) (int64, error) +- func (*PgStore) Create(context.Context, Driver) error +- func (*PgStore) Get(context.Context, string) (Driver, error) +- func (*PgStore) GetByNickname(context.Context, string) (Driver, error) +- func (*PgStore) List(context.Context, int, int, string) ([]Driver, error) +- func (*PgStore) Update(context.Context, Driver) error +- func (*PgStore) Delete(context.Context, string) error +- func (*PgStore) Count(context.Context) (int, error) + +--- + +## Package `realtime` (`internal/realtime`) + +Package realtime manages WebSocket connections: registration, fan-out of +race snapshots, and per-client send queues with backpressure handling. + +### Structs +- **struct Client** { ID, Send, SessionID, Done, DriverID, LastUDPCommand } +- **struct Hub** +- **struct Stats** { Connections, DropsTotal } + +### Functions +- func NewHub() *Hub +- func MustEnvelope(transport.MessageType, any) *transport.Envelope + +### Methods +- func (*Client) Close() +- func (*Hub) Stats() Stats +- func (*Hub) Register(*Client) +- func (*Hub) Unregister(*Client) +- func (*Hub) Run(context.Context) +- func (*Hub) Stop() +- func (*Hub) Publish(*transport.Envelope) + +--- + +## Package `main` (`.`) + +Smoke test: ws client connects, exchanges a few messages, exits. +Run: go run ./smoke_test.go + +--- + +## Package `catalog` (`internal/catalog`) + +Package catalog owns the static configuration entities: tracks and +cars. Both are first-class resources that drivers browse, pick, and +(in later phases) author. + +PoC: in-memory cache backed by a pluggable Store. The runtime uses +postgres.PgStore so tracks / cars survive restarts. The cache is the +source of truth for read paths; the Store is the source of truth for +durability. Mutations go through the Store, then refresh the cache. + +Design constraints come from the user's apartment layout: + - largest room: 4.46 m × 3.19 m (446 × 319 cm) + - car scale: 1/27 (Compact) + - printer: Bambu A1 (220×220×250 mm build volume) + +Tracks are sized to fit a 4.5 × 3.2 m bounding box with a safety +margin, which keeps them physically realizable in the apartment. +Cars are sized to a 1/27 touring envelope (~80 × 35 × 25 mm). + +### Interfaces +- **interface Store** { Load, UpsertTrack, DeleteTrack, UpsertCar, DeleteCar, UpdateCarStats, GetTrackCalendar, CreateTrackCalendar, DeleteTrackCalendar } + +### Structs +- **struct CreateTrackOptions** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline } +- **struct UpdateTrackOptions** { Name, Description, Visibility, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline } +- **struct CreateCarOptions** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, AvatarURL, Active, DeviceID } +- **struct UpdateCarOptions** { Name, Visibility, LengthMm, WidthMm, HeightMm, WeightG, TopSpeedMs, ColorHex, Active, Chassis, Motor, Battery, Drive, DeviceID } +- **struct Waypoint** { X, Y, HeadingRad, SpeedMs, Curvature } +- **struct Bounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM } +- **struct TrackMeta** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Bounds, Surface, Tags, Centerline, BestLapMs, BestLapHolder, CreatedMs, UpdatedMs } +- **struct TrackCalendarEntry** { ID, TrackID, StartAt, EndAt } +- **struct MotorSpec** { Kind, Class, KV, PowerW } +- **struct BatterySpec** { VoltageV, CapacityMah, Cells, Chemistry } +- **struct ChassisSpec** { Model, Material, Printed, WheelbaseMm, TrackMm } +- **struct CarMeta** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, DeviceID, TopSpeedMs, ColorHex, AvatarURL, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs, CreatedMs, UpdatedMs } +- **struct Snapshot** { GeneratedMs, Version, Tracks, Cars } +- **struct Stats** { TracksTotal, TracksSystem, TracksPublic, TracksPrivate, CarsTotal, CarsSystem, CarsPublic, CarsPrivate, CarsActive } +- **struct Event** { Kind, TrackID, CarID, Snapshot } +- **struct Service** + +### Functions +- func NewService(context.Context, Store) (*Service, error) + +### Methods +- func (CreateTrackOptions) Validate() error +- func (*Service) CreateTrack(context.Context, CreateTrackOptions) (TrackMeta, error) +- func (*Service) UpdateTrack(context.Context, string, UpdateTrackOptions) (TrackMeta, error) +- func (*Service) DeleteTrack(context.Context, string) error +- func (CreateCarOptions) Validate() error +- func (*Service) CreateCar(context.Context, CreateCarOptions) (CarMeta, error) +- func (*Service) UpdateCar(context.Context, string, UpdateCarOptions) (CarMeta, error) +- func (*Service) DeleteCar(context.Context, string) error +- func (*Service) SetCarStats(context.Context, string, func) error +- func (*Service) ListTracksByVisibility(Visibility) []TrackMeta +- func (*Service) ListCarsByVisibility(Visibility) []CarMeta +- func (*Service) ListCarsByOwner(string) []CarMeta +- func (*Service) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error) +- func (*Service) CreateTrackCalendar(context.Context, string, time.Time, time.Time) (TrackCalendarEntry, error) +- func (*Service) DeleteTrackCalendar(context.Context, int) error +- func (*memoryStore) Load(context.Context) ([]TrackMeta, []CarMeta, error) +- func (*memoryStore) UpsertTrack(context.Context, TrackMeta) error +- func (*memoryStore) DeleteTrack(context.Context, string) error +- func (*memoryStore) UpsertCar(context.Context, CarMeta) error +- func (*memoryStore) DeleteCar(context.Context, string) error +- func (*memoryStore) UpdateCarStats(context.Context, string, func) error +- func (*memoryStore) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error) +- func (*memoryStore) CreateTrackCalendar(context.Context, TrackCalendarEntry) (TrackCalendarEntry, error) +- func (*memoryStore) DeleteTrackCalendar(context.Context, int) error +- func (*Service) Subscribe() (?, func) +- func (*Service) Stop() +- func (*Service) Done() ? +- func (*Service) Snapshot() Snapshot +- func (*Service) Stats() Stats +- func (*Service) ListTracks() []TrackMeta +- func (*Service) ListCars() []CarMeta +- func (*Service) GetTrack(string) (TrackMeta, bool) +- func (*Service) GetCar(string) (CarMeta, bool) + +--- + +## Package `seed` (`internal/races/seed`) + +### Structs +- **struct DefaultCounts** { Finished, Live, Plans } +- **struct Options** { Counts, Seed, Reset, Now } +- **struct Summary** { FinishedInserted, LiveCreated, PlansInserted, QueueInserted, Duration } +- **struct Runner** + +### Functions +- func NewRunner(*races.PgStore, *lobby.Service, *races.LiveStore, *clans.PgStore, *drivers.PgStore) *Runner + +### Methods +- func (*Runner) Run(context.Context, Options) (Summary, error) + +--- + +## Package `stats` (`internal/stats`) + +Package stats collects and exposes server and per-driver metrics. + +PoC: in-memory only, atomic counters where possible, mutex around +driver and race records. The data lives for the lifetime of the +process. A future storage layer will swap this for Postgres/Redis. + +Public surface is split into: + - lifecycle hooks (Connect/Disconnect/Race*/Lap*/Input*/RTT) — + called from cmd/poc-server and from internal/control and lobby + - Snapshot() — full server snapshot for /stats/detailed + - DriverProfile(id) — per-driver summary + - RecentLaps(n) — last N laps across all races + +### Structs +- **struct ServerStats** { StartedMs, UptimeMs, CurrentClients, PeakClients, TotalConnections, TotalDisconnects, TotalRacesCreated, TotalRacesStarted, TotalRacesFinished, TotalLapsRecorded, TotalInputsRecv, TotalSnapshotsSent, SnapshotsDropped, AvgRTTMs, RecentRTTs } +- **struct DriverProfile** { ID, Name, TotalRacesStarted, TotalRacesFinished, TotalLaps, BestLapMs, LastLapMs, TotalDistanceM, TotalPlaytimeMs, Wins, Podiums, LastSeenMs } +- **struct LapRecord** { RaceID, DriverID, LapMs, Sector1Ms, Sector2Ms, Sector3Ms, Position, TSMs } +- **struct RaceResult** { RaceID, DriverID, FinishedAtMs, Position, TotalLaps, BestLapMs, TotalTimeMs, DNF } +- **struct Collector** + +### Functions +- func NewCollector() *Collector + +### Methods +- func (*Collector) Start() +- func (*Collector) OnConnect(string, string) +- func (*Collector) OnDisconnect(string, int64) +- func (*Collector) OnRaceCreated() +- func (*Collector) OnRaceStarted([]string) +- func (*Collector) OnRaceFinished(RaceResult) +- func (*Collector) OnLapRecorded(LapRecord) +- func (*Collector) OnInputProcessed() +- func (*Collector) OnSnapshotSent(uint64, uint64) +- func (*Collector) OnRTT(int64) +- func (*Collector) OnDistance(string, float64) +- func (*Collector) Server() ServerStats +- func (*Collector) Driver(string, string) DriverProfile +- func (*Collector) Drivers() []DriverProfile +- func (*Collector) RecentLaps(int) []LapRecord +- func (*Collector) RecentResults(int) []RaceResult + +--- + +## Package `main` (`scripts/genseed`) + +Package main contains the genseed utility. It is built into a standalone +binary via `go build ./scripts/genseed` and is not part of the runtime. + +Usage: + + go run ./scripts/genseed seeds.json > 002_seed.sql + +It reads the JSON dump produced by `DUMP_SEEDS=1 go test ./internal/catalog` +and emits ON CONFLICT-safe INSERTs for the catalog tables. + +--- + +## Package `main` (`scripts/genseed-json`) + +Package main contains a small helper that prints the default seeds +(5 F1 tracks + 5 F1 cars) as JSON to stdout. It is run as: + + go run ./scripts/genseed-json > seeds.json + +which is then fed into scripts/genseed to regenerate the SQL. + +--- + +## Package `main` (`scripts/graphify`) + +Package main is the graphify utility. It walks the codebase, parses Go AST, +and outputs a highly compact symbol summary to server/CODEBASE.md. +This is used for token savings and helping developers/AI agents quickly map out imports and structures. + +### Structs +- **struct PackageSummary** { Name, Path, Doc, Structs, Interfaces, Functions, Methods } + +--- + ## Package `seeddefs` (`internal/catalog/seeddefs`) Package seeddefs holds the canonical seed definitions for tracks and @@ -645,96 +753,3 @@ the same source-of-truth data. --- -## Package `lobby` (`internal/lobby`) - -Package lobby owns the "outside any race" state of the server: which -races are open, which drivers are connected but idle, who is the host -of each race. - -The lobby is the single source of truth for matchmaking and for -rendering the "what races are available right now" list. It does NOT -own per-race physics — that's the Engine (internal/control). - -All operations are thread-safe. Mutations broadcast a snapshot event -to subscribers (see Service.Subscribe). - -### Interfaces -- **interface Persistence** { UpsertRace, DeleteRace, AddDriver, RemoveDriver, SetRaceStatus, UpsertDriver, DeleteDriver } - -### Structs -- **struct CreateRaceOptions** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS } -- **struct RaceMeta** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, DriverIDs, Status, CreatedMs, StartedMs } -- **struct DriverMeta** { ID, Name, Nickname, AvatarURL, ClanID, ClanTag, Status, RaceID, ConnectedMs, LastSeenMs, Country, AvatarHue, CarID } -- **struct Snapshot** { GeneratedMs, Races, Drivers, Version } -- **struct Stats** { RacesTotal, RacesOpen, RacesActive, DriversIdle, DriversTotal } -- **struct Service** - -### Functions -- func NewService() *Service - -### Methods -- func (*Service) CreateRace(CreateRaceOptions) (RaceMeta, error) -- func (*Service) AddRace(RaceMeta) -- func (*Service) AddDriver(string, string, string) (DriverMeta, error) -- func (*Service) RemoveDriver(string) -- func (*Service) Heartbeat(string) -- func (*Service) SetDriverProfile(string, string, string, string, string) -- func (*Service) AddDriverToRace(string, string) error -- func (*Service) RemoveDriverFromRace(string) -- func (*Service) SetRaceStatus(string, RaceStatus) error -- func (*Service) DeleteRace(string) error -- func (*Service) QuickPlay(string, int) (RaceMeta, error) -- func (*Service) GetRace(string) (RaceMeta, error) -- func (*Service) GetDriver(string) (DriverMeta, error) -- func (*Service) SelectCar(string, *string) error -- func (*Service) ListRaces() []RaceMeta -- func (*Service) ListDrivers() []DriverMeta -- func (*Service) SetPersistence(Persistence) -- func (*Service) SetRaceLifecycleHook(func, func) -- func (*Service) Subscribe() (?, func) -- func (*Service) Stop() -- func (*Service) Done() ? -- func (*Service) Snapshot() Snapshot -- func (*Service) Stats() Stats - ---- - -## Package `realtime` (`internal/realtime`) - -Package realtime manages WebSocket connections: registration, fan-out of -race snapshots, and per-client send queues with backpressure handling. - -### Structs -- **struct Client** { ID, Send, SessionID, Done, DriverID, LastUDPCommand } -- **struct Hub** -- **struct Stats** { Connections, DropsTotal } - -### Functions -- func NewHub() *Hub -- func MustEnvelope(transport.MessageType, any) *transport.Envelope - -### Methods -- func (*Client) Close() -- func (*Hub) Stats() Stats -- func (*Hub) Register(*Client) -- func (*Hub) Unregister(*Client) -- func (*Hub) Run(context.Context) -- func (*Hub) Stop() -- func (*Hub) Publish(*transport.Envelope) - ---- - -## Package `main` (`scripts/genseed`) - -Package main contains the genseed utility. It is built into a standalone -binary via `go build ./scripts/genseed` and is not part of the runtime. - -Usage: - - go run ./scripts/genseed seeds.json > 002_seed.sql - -It reads the JSON dump produced by `DUMP_SEEDS=1 go test ./internal/catalog` -and emits ON CONFLICT-safe INSERTs for the catalog tables. - ---- - diff --git a/server/cmd/poc-server/main.go b/server/cmd/poc-server/main.go index cf479c6..f2693f3 100644 --- a/server/cmd/poc-server/main.go +++ b/server/cmd/poc-server/main.go @@ -38,6 +38,7 @@ import ( "net/http" "os" "os/signal" + "strings" "sync" "syscall" "time" @@ -51,6 +52,7 @@ import ( // the // @... annotations on the handlers. _ "github.com/x0gp/server/docs" + "github.com/x0gp/server/internal/auth" "github.com/x0gp/server/internal/catalog" "github.com/x0gp/server/internal/clans" "github.com/x0gp/server/internal/config" @@ -288,35 +290,40 @@ func main() { // Start WebRTC service webrtcSvc.Start(ctx, &wg) + apiMux := http.NewServeMux() + apiMux.HandleFunc("/api/version", versionHandler()) + apiMux.HandleFunc("/api/catalog", catalogHandler(cat)) + apiMux.HandleFunc("/api/tracks", tracksRouter(cat)) + apiMux.HandleFunc("/api/tracks/", trackByIDHandler(cat)) + apiMux.HandleFunc("/api/tracks/calendar", trackCalendarRouter(cat)) + apiMux.HandleFunc("/api/tracks/calendar/", deleteTrackCalendarHandler(cat)) + apiMux.HandleFunc("/api/cars", carsRouter(cat)) + apiMux.HandleFunc("/api/cars/", carByIDHandler(cat)) + apiMux.HandleFunc("/api/races", racesListHandler(racesSvc)) + apiMux.HandleFunc("/api/races/last", racesGetLastHandler(racesSvc)) + apiMux.HandleFunc("/api/races/", racesGetHandler(racesSvc)) + apiMux.HandleFunc("/api/races/upcoming", racesUpcomingHandler(racesSvc)) + apiMux.HandleFunc("/api/races/queue", racesQueueRouter(racesSvc)) + apiMux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc)) + apiMux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc)) + apiMux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc)) + apiMux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc)) + apiMux.HandleFunc("/api/leaderboard/tracks", calendarLeaderboardHandler(racesSvc)) + apiMux.HandleFunc("/api/clans", clansRouter(clansSvc)) + apiMux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc)) + apiMux.HandleFunc("/api/drivers", driversRouter(driversSvc)) + apiMux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc)) + apiMux.HandleFunc("/api/video/stream", videoSvc.StreamHandler()) + apiMux.HandleFunc("/api/video/control", videoSvc.ControlHandler()) + apiMux.HandleFunc("/api/webrtc/connect", webrtcSvc.ConnectHandler()) + + authMW := auth.Middleware(cfg.JWTSecret, cfg.DevMode) + mux := http.NewServeMux() mux.HandleFunc("/health", healthHandler(hub, engine)) - mux.HandleFunc("/api/version", versionHandler()) mux.HandleFunc("/stats", statsHandler(hub, engine)) - mux.HandleFunc("/api/catalog", catalogHandler(cat)) - mux.HandleFunc("/api/tracks", tracksRouter(cat)) - mux.HandleFunc("/api/tracks/", trackByIDHandler(cat)) - mux.HandleFunc("/api/tracks/calendar", trackCalendarRouter(cat)) - mux.HandleFunc("/api/tracks/calendar/", deleteTrackCalendarHandler(cat)) - mux.HandleFunc("/api/cars", carsRouter(cat)) - mux.HandleFunc("/api/cars/", carByIDHandler(cat)) - mux.HandleFunc("/api/races", racesListHandler(racesSvc)) - mux.HandleFunc("/api/races/last", racesGetLastHandler(racesSvc)) - mux.HandleFunc("/api/races/", racesGetHandler(racesSvc)) - mux.HandleFunc("/api/races/upcoming", racesUpcomingHandler(racesSvc)) - mux.HandleFunc("/api/races/queue", racesQueueRouter(racesSvc)) - mux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc)) - mux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc)) - mux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc)) - mux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc)) - mux.HandleFunc("/api/leaderboard/tracks", calendarLeaderboardHandler(racesSvc)) - mux.HandleFunc("/api/clans", clansRouter(clansSvc)) - mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc)) - mux.HandleFunc("/api/drivers", driversRouter(driversSvc)) - mux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc)) - mux.HandleFunc("/api/video/stream", videoSvc.StreamHandler()) - mux.HandleFunc("/api/video/control", videoSvc.ControlHandler()) - mux.HandleFunc("/api/webrtc/connect", webrtcSvc.ConnectHandler()) - mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, lobbySvc, driversSvc, videoSvc, logger)) + mux.Handle("/api/", authMW(apiMux)) + mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, lobbySvc, driversSvc, clansSvc, videoSvc, logger)) // Static client files (resolving TODO) mux.Handle("/", http.FileServer(http.Dir("./web"))) @@ -485,7 +492,7 @@ func statsHandler(h *Hub, e *control.Engine) http.HandlerFunc { // @Tags websocket // @Success 101 {object} transport.Envelope "Switching protocols. Subsequent frames follow the envelope schema." // @Router /ws [get] -func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, videoSvc *UDPVideoService, logger *slog.Logger) http.HandlerFunc { +func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, clansSvc *clans.Service, videoSvc *UDPVideoService, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { @@ -503,7 +510,7 @@ func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Servi "remote", r.RemoteAddr, ) go writePump(client, conn, logger) - go readPump(cfg, client, conn, e, h, cat, lobbySvc, driversSvc, videoSvc, logger) + go readPump(cfg, client, conn, e, h, cat, lobbySvc, driversSvc, clansSvc, videoSvc, logger) } } @@ -545,7 +552,7 @@ func writePump(c *realtime.Client, conn *websocket.Conn, logger *slog.Logger) { } } -func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, videoSvc *UDPVideoService, logger *slog.Logger) { +func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, clansSvc *clans.Service, videoSvc *UDPVideoService, logger *slog.Logger) { defer func() { h.Unregister(c) logger.Info("client disconnected", "client_id", c.ID) @@ -577,7 +584,7 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c switch env.Type { case transport.TypeClientHello: - handleClientHello(c, e, cfg, lobbySvc, driversSvc, env) + handleClientHello(c, e, cfg, lobbySvc, driversSvc, clansSvc, env) case transport.TypeJoinRace: handleJoinRace(c, e, lobbySvc, cat, env) case transport.TypeLeaveRace: @@ -608,7 +615,7 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c // Message handlers -------------------------------------------------------- -func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config, lobbySvc *lobby.Service, driversSvc *drivers.Service, env *transport.Envelope) { +func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config, lobbySvc *lobby.Service, driversSvc *drivers.Service, clansSvc *clans.Service, env *transport.Envelope) { hello := transport.ServerHello{ SessionID: c.ID, RaceTick: 0, @@ -619,24 +626,113 @@ func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config hello.Config.MaxCars = 4 c.SessionID = c.ID - // Extract client_id (DriverID) if present in payload payload, _ := env.Payload.(map[string]any) - if payload != nil { - if clientID, ok := payload["client_id"].(string); ok && clientID != "" { - c.DriverID = clientID - // Pre-populate driver in the lobby - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - if drv, err := driversSvc.Get(ctx, clientID); err == nil { - _, _ = lobbySvc.AddDriver(clientID, drv.Name, "") - lobbySvc.SetDriverProfile(clientID, drv.Nickname, drv.AvatarURL, drv.ClanID, "") - } - cancel() + var clientID string + var email string + + if cfg.DevMode { + // In devMode, client_id is optional and can be arbitrary + if cid, ok := payload["client_id"].(string); ok && cid != "" { + clientID = cid + } else { + clientID = c.ID } + } else { + // In production, require valid Supabase JWT + tokenStr, _ := payload["token"].(string) + if tokenStr == "" { + sendError(c, "unauthorized", "missing auth token in client_hello") + c.Close() + return + } + + claims, err := auth.ValidateJWT(tokenStr, cfg.JWTSecret) + if err != nil { + sendError(c, "unauthorized", fmt.Sprintf("invalid auth token: %v", err)) + c.Close() + return + } + clientID = claims.DriverID + email = claims.Email + } + + c.DriverID = clientID + + // Resolve/ensure profile and join lobby + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + drv, clanTag, err := ensureDriverProfile(ctx, clientID, email, driversSvc, clansSvc) + if err == nil { + _, _ = lobbySvc.AddDriver(clientID, drv.Name, "") + lobbySvc.SetDriverProfile(clientID, drv.Nickname, drv.AvatarURL, drv.ClanID, clanTag) + } else { + // Fallback placeholder profile if database error + _, _ = lobbySvc.AddDriver(clientID, "Driver", "") + lobbySvc.SetDriverProfile(clientID, "DRV", "", "", "") } c.Send <- mustEncode(transport.TypeServerHello, hello) } +func ensureDriverProfile(ctx context.Context, id string, email string, driversSvc *drivers.Service, clansSvc *clans.Service) (drivers.Driver, string, error) { + drv, err := driversSvc.Get(ctx, id) + if err == nil { + var clanTag string + if drv.ClanID != "" { + if cl, err := clansSvc.Get(ctx, drv.ClanID); err == nil { + clanTag = cl.Tag + } + } + return drv, clanTag, nil + } + + // Profile not found: auto-create a default profile + name := "Driver" + if email != "" { + if idx := strings.Index(email, "@"); idx > 0 { + name = email[:idx] + } + } + // Generate unique 3-letter nickname + nick := "DRV" + if len(name) >= 3 { + candidate := strings.ToUpper(name[:3]) + if isAlphaOnly(candidate) { + nick = candidate + } + } + + // Check if nickname already exists + for i := 0; i < 20; i++ { + _, err := driversSvc.GetByNickname(ctx, nick) + if err != nil && errors.Is(err, drivers.ErrNotFound) { + break + } + nick = fmt.Sprintf("D%02d", i) + } + + created, err := driversSvc.Create(ctx, drivers.CreateInput{ + ID: id, + Nickname: nick, + Name: name, + }) + if err != nil { + return drivers.Driver{}, "", err + } + return created, "", nil +} + +func isAlphaOnly(s string) bool { + for _, c := range s { + if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') { + return false + } + } + return true +} + + func handleJoinRace(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service, cat *catalog.Service, env *transport.Envelope) { payload, _ := env.Payload.(map[string]any) slot := 0 diff --git a/server/go.mod b/server/go.mod index 72eb616..469cf8b 100644 --- a/server/go.mod +++ b/server/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/spec v0.20.6 // indirect github.com/go-openapi/swag v0.19.15 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/server/go.sum b/server/go.sum index 5c0ddd3..1ea0066 100644 --- a/server/go.sum +++ b/server/go.sum @@ -14,6 +14,8 @@ github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go new file mode 100644 index 0000000..9eeeb65 --- /dev/null +++ b/server/internal/auth/auth.go @@ -0,0 +1,105 @@ +// Package auth handles Supabase JWT validation and HTTP middleware. +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +type contextKey int + +const ( + driverIDKey contextKey = iota + emailKey +) + +// Claims represents the parsed Supabase JWT claims we care about. +type Claims struct { + DriverID string + Email string +} + +// ValidateJWT parses and validates a Supabase HMAC-SHA256 JWT using the secret. +func ValidateJWT(tokenStr string, secret string) (Claims, error) { + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return []byte(secret), nil + }) + if err != nil { + return Claims{}, err + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + sub, _ := claims["sub"].(string) + email, _ := claims["email"].(string) + if sub == "" { + return Claims{}, errors.New("missing sub claim") + } + return Claims{ + DriverID: sub, + Email: email, + }, nil + } + + return Claims{}, errors.New("invalid token") +} + +// Middleware returns an HTTP middleware that extracts and validates the Bearer token. +// When devMode is enabled, authentication is bypassed entirely. +func Middleware(secret string, 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 { + next.ServeHTTP(w, r) + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized","message":"missing authorization header"}`)) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized","message":"invalid authorization format"}`)) + return + } + + claims, err := ValidateJWT(parts[1], secret) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(fmt.Sprintf(`{"error":"unauthorized","message":%q}`, err.Error()))) + return + } + + ctx := context.WithValue(r.Context(), driverIDKey, claims.DriverID) + ctx = context.WithValue(ctx, emailKey, claims.Email) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// DriverIDFromContext retrieves the driver ID from the context. +func DriverIDFromContext(ctx context.Context) (string, bool) { + id, ok := ctx.Value(driverIDKey).(string) + return id, ok +} + +// EmailFromContext retrieves the email from the context. +func EmailFromContext(ctx context.Context) (string, bool) { + email, ok := ctx.Value(emailKey).(string) + return email, ok +} diff --git a/server/internal/auth/auth_test.go b/server/internal/auth/auth_test.go new file mode 100644 index 0000000..42cb187 --- /dev/null +++ b/server/internal/auth/auth_test.go @@ -0,0 +1,125 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const testSecret = "my-super-secret-supabase-jwt-key" + +func generateTestToken(sub string, email string, expiresAt time.Time, secret string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": sub, + "email": email, + "exp": expiresAt.Unix(), + }) + return token.SignedString([]byte(secret)) +} + +func TestValidateJWT(t *testing.T) { + // 1. Valid token + validToken, err := generateTestToken("driver-uuid-123", "driver@test.com", time.Now().Add(time.Hour), testSecret) + if err != nil { + t.Fatalf("failed to generate valid token: %v", err) + } + + claims, err := ValidateJWT(validToken, testSecret) + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + if claims.DriverID != "driver-uuid-123" || claims.Email != "driver@test.com" { + t.Errorf("unexpected claims: %+v", claims) + } + + // 2. Expired token + expiredToken, err := generateTestToken("driver-uuid-123", "driver@test.com", time.Now().Add(-time.Hour), testSecret) + if err != nil { + t.Fatalf("failed to generate expired token: %v", err) + } + + _, err = ValidateJWT(expiredToken, testSecret) + if err == nil { + t.Error("expected error for expired token, got nil") + } + + // 3. Invalid signature + _, err = ValidateJWT(validToken, "wrong-secret-key") + if err == nil { + t.Error("expected error for wrong signature, got nil") + } +} + +func TestAuthMiddleware(t *testing.T) { + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + driverID, ok := DriverIDFromContext(r.Context()) + if ok { + w.Header().Set("X-Driver-ID", driverID) + } + w.WriteHeader(http.StatusOK) + }) + + // 1. DevMode = true: bypass authentication + mwDev := Middleware(testSecret, true)(nextHandler) + req := httptest.NewRequest(http.MethodGet, "/api/races", nil) + w := httptest.NewRecorder() + mwDev.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK in DevMode, got %d", w.Code) + } + + // 2. DevMode = false: missing Authorization header + mwProd := Middleware(testSecret, false)(nextHandler) + w = httptest.NewRecorder() + mwProd.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 Unauthorized for missing auth header, got %d", w.Code) + } + + // 3. DevMode = false: invalid Authorization format + reqFormat := httptest.NewRequest(http.MethodGet, "/api/races", nil) + reqFormat.Header.Set("Authorization", "InvalidFormat token123") + w = httptest.NewRecorder() + mwProd.ServeHTTP(w, reqFormat) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 Unauthorized for invalid format, got %d", w.Code) + } + + // 4. DevMode = false: valid token + validToken, err := generateTestToken("driver-uuid-123", "driver@test.com", time.Now().Add(time.Hour), testSecret) + if err != nil { + t.Fatalf("failed to generate valid token: %v", err) + } + + reqValid := httptest.NewRequest(http.MethodGet, "/api/races", nil) + reqValid.Header.Set("Authorization", "Bearer "+validToken) + w = httptest.NewRecorder() + mwProd.ServeHTTP(w, reqValid) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK for valid token, got %d", w.Code) + } + if w.Header().Get("X-Driver-ID") != "driver-uuid-123" { + t.Errorf("expected X-Driver-ID header to be set in context, got %q", w.Header().Get("X-Driver-ID")) + } +} + +func TestContextHelpers(t *testing.T) { + ctx := context.Background() + if _, ok := DriverIDFromContext(ctx); ok { + t.Error("expected ok=false for empty context") + } + + ctx = context.WithValue(ctx, driverIDKey, "test-driver") + id, ok := DriverIDFromContext(ctx) + if !ok || id != "test-driver" { + t.Errorf("expected test-driver, got %s (ok=%t)", id, ok) + } +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 076b1c9..a848886 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -26,6 +26,7 @@ type Config struct { DevMode bool // If true, allow multiple clients without auth DatabaseURL string // Postgres connection string; required at runtime + JWTSecret string // Supabase JWT Secret for signature verification UDPVideoAddr string // UDP address to listen on for video, e.g. ":9999" ESPCmdPort int // Port on ESP32 that listens for commands, e.g. 8888 @@ -39,6 +40,14 @@ type Config struct { func Load() (*Config, error) { loadDotEnv() + jwtSecret := getEnv("X0GP_JWT_SECRET", "") + if jwtSecret == "" { + jwtSecret = getEnv("JWT_SECRET", "") + } + if jwtSecret == "" { + jwtSecret = getEnv("SUPABASE_JWT_SECRET", "") + } + c := &Config{ HTTPAddr: getEnv("X0GP_HTTP_ADDR", ":8080"), TickRate: getEnvInt("X0GP_TICK_RATE", 60), @@ -47,10 +56,15 @@ func Load() (*Config, error) { SnapshotRate: getEnvInt("X0GP_SNAPSHOT_RATE", 30), DevMode: getEnvBool("X0GP_DEV_MODE", true), DatabaseURL: getEnv("DATABASE_URL", ""), + JWTSecret: jwtSecret, UDPVideoAddr: getEnv("X0GP_UDP_VIDEO_ADDR", ":9999"), ESPCmdPort: getEnvInt("X0GP_ESP_CMD_PORT", 8888), } + if !c.DevMode && c.JWTSecret == "" { + return nil, fmt.Errorf("JWT secret (X0GP_JWT_SECRET / JWT_SECRET) is required when DevMode is disabled") + } + if c.TickRate <= 0 || c.TickRate > 240 { return nil, fmt.Errorf("X0GP_TICK_RATE must be in (0, 240], got %d", c.TickRate) }