Add Supabase JWT authorization middleware, config parsing, and WebSocket token verification

This commit is contained in:
2026-07-17 22:46:26 +04:00
parent 9c9632343d
commit a5d2ee32c6
8 changed files with 753 additions and 395 deletions
+2 -2
View File
@@ -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
# JWT secret (Supabase service role or JWT secret). Required when X0GP_DEV_MODE=false.
X0GP_JWT_SECRET=your-supabase-jwt-secret-here
+366 -351
View File
@@ -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.
---
+138 -42
View File
@@ -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
+1
View File
@@ -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
+2
View File
@@ -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=
+105
View File
@@ -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
}
+125
View File
@@ -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)
}
}
+14
View File
@@ -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)
}