mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-18 13:27:03 +00:00
Add Supabase JWT authorization middleware, config parsing, and WebSocket token verification
This commit is contained in:
+2
-2
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user