Otomo — HTTP packets (staging @ 9b868e6)¶
Status (2026-09-28): reflects staging at 9b868e6 — the admin-auth, config, patch,
dashboard and admin-ui services are in the tree and in deploy/compose.yaml; the packets
below are read from their handlers. Compose starts auth and session (SCRUM-268) alongside the rest; allocator and
matchmaker are still deliberately absent. Session's routes still answer 501 behind
its guards, so a 501 from a session route through the gateway means both token checks
passed.
Handler = file:line of the code that answers; line numbers are for staging 9b868e6.
Every exchange as literal wire packets. No symbols, no lookup table: each Response cell is the complete status line, headers and body. Rows marked spec are what a handler that does not exist yet will answer.
request_id echoes the inbound X-Request-Id or a generated one, so it varies per
request; 0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d stands in for it everywhere below.
1. Player edge — gateway, public listener :8080¶
| # | Request | Response (today) | Handler | Why |
|---|---|---|---|---|
| 1 | POST /auth/anonymous HTTP/1.1Host: <gateway>Content-Type: application/json{"device_id":"q3JqN1mG4c8u9xYwT0bVn5dKs2LpR7eHf6AzWiUoQyE"} |
HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: no-storeX-Request-Id: 0192f3a4-…{"schema_version":1,"access_token":"eyJhbGciOiJFZERTQSIs…","expires_in":900,"refresh_token":"n3Jq…","services":{"session":"http://localhost:8080/api/player/session","match":null}}(proxied to auth; the gateway adds nothing but X-Request-Id) |
services/gateway/internal/router/player.go:11 → services/auth/internal/api/anonymous.go:99 |
Wildcard subtree, so Auth's routes need no gateway redeploy. Strictest limiter — unauthenticated token minting. §3 rows 2–4 have every answer |
| 2 | GET /patch/v1/live/manifest HTTP/1.1Host: <gateway>If-None-Match: "9f2c…" |
HTTP/1.1 304 Not ModifiedETag: "9f2c…"Cache-Control: no-cacheX-Min-Client-Version: 1.4.0(empty body) |
services/gateway/internal/router/player.go:12 → services/patch/internal/api/manifest.go:49 |
Public: a client must patch before it can log in. Steady state is one request answering 304 |
| 3 | GET /patch/v1/blob/9f2c… HTTP/1.1Host: <gateway>Range: bytes=1048576- |
HTTP/1.1 206 Partial ContentContent-Type: application/octet-streamContent-Range: bytes 1048576-48213903/48213904Cache-Control: public, max-age=31536000, immutableETag: "9f2c…"<binary> |
services/gateway/internal/router/player.go:17 → services/patch/internal/api/blob.go:42 |
Content-addressed ⇒ immutable ⇒ cache forever, Range resume. The player gateway registers GET /patch/v1/blob/ as Stream: true (write deadline cleared), public for every channel |
| 4 | GET /patch/v1/dev/manifest HTTP/1.1Host: <gateway>Authorization: Bearer <staff jwt> |
HTTP/1.1 200 OKContent-Type: application/jsonETag: "9f2c…"Cache-Control: no-cacheX-Min-Client-Version: 1.4.0{"channel":"dev","release_id":41,"min_client_version":"1.4.0","namespaces":[…],"packs":[…]} |
services/gateway/internal/router/player.go:18 → services/patch/internal/api/manifest.go:49 |
Unshipped builds → staff-gated, on the player edge because the traffic is Patch. This is why the player gateway loads the staff JWKS |
| 5 | GET /patch/v1/staging/manifest HTTP/1.1Host: <gateway>Authorization: Bearer <staff jwt> |
HTTP/1.1 200 OKContent-Type: application/jsonETag: "9f2c…"Cache-Control: no-cacheX-Min-Client-Version: 1.4.0{"channel":"staging","release_id":41,…} |
services/gateway/internal/router/player.go:19 → services/patch/internal/api/manifest.go:49 |
Same; the service re-verifies the staff bearer (defence in depth) |
| 6 | GET /api/player/session/events?after=412 HTTP/1.1Host: <gateway>Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}(stream route: write deadline cleared, FlushInterval=-1) |
services/gateway/internal/router/player.go:20 → services/session/internal/server/auth.go:29 → services/session/internal/api/stubs.go:19 |
The long-poll holds ~25 s, past the 30 s server write timeout, so this route must clear its deadline. The 501 is Session's placeholder, reached only after both guards admitted the token |
| 7 | GET /api/player/session/me HTTP/1.1Host: <gateway>Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway/internal/router/player.go:21 → services/session/internal/server/auth.go:29 → services/session/internal/api/stubs.go:19 |
Player domain only. A staff token here fails invalid_signature before any issuer comparison. deploy/scripts/smoke-player-login.sh asserts exactly this 501 |
| 8 | GET /api/admin/config/namespaces HTTP/1.1Host: <gateway>Authorization: Bearer <staff jwt> |
HTTP/1.1 404 Not FoundContent-Type: application/json{"error":{"code":"not_found","message":"the requested path does not exist","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway/gateway.go:182 |
The admin route table is not in this binary at all — reachability is not a runtime env var |
| 9 | GET /.well-known/jwks.json HTTP/1.1Host: <gateway> |
HTTP/1.1 404 Not FoundContent-Type: application/json{"error":{"code":"not_found","message":"the requested path does not exist","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway/gateway.go:182 |
Key material is fetched east-west, never proxied. No client verifies a JWT itself |
| 10 | GET /nope HTTP/1.1Host: <gateway> |
HTTP/1.1 404 Not FoundContent-Type: application/json{"error":{"code":"not_found","message":"the requested path does not exist","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway/gateway.go:182 |
/ catch-all. Deliberately not rate limited: a 404 costs nothing, and limiting it would turn the limiter into a way to map the surface |
| 11 | DELETE /patch/v1/live/manifest HTTP/1.1Host: <gateway> |
HTTP/1.1 404 Not FoundContent-Type: application/json{"error":{"code":"not_found","message":"the requested path does not exist","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway/gateway.go:182 |
The method-less / catch-all matches, so a wrong method is a 404 here — not the 405 a service returns |
| 12 | GET /api/player/session/me HTTP/1.1Host: <gateway>Authorization: Bearer <expired player jwt> |
HTTP/1.1 401 UnauthorizedContent-Type: application/json{"error":{"code":"expired","message":"the access token has expired","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}(no WWW-Authenticate header is sent) |
services/gateway/internal/authn/middleware.go:44 |
Right domain, unusable token → the client should refresh once and retry once |
| 13 | GET /api/player/session/me HTTP/1.1Host: <gateway>Authorization: Bearer <staff jwt> |
HTTP/1.1 401 UnauthorizedContent-Type: application/json{"error":{"code":"invalid_signature","message":"the access token is not valid","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway/internal/authn/middleware.go:44 |
The staff kid is absent from the player key set, so verification never reaches the issuer check |
| 14 | GET /api/player/session/me HTTP/1.1Host: <gateway>(no Authorization) |
HTTP/1.1 401 UnauthorizedContent-Type: application/json{"error":{"code":"missing_token","message":"an access token is required","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway/internal/authn/middleware.go:44 |
401, never 403: nothing was presented, so there is no role to be short of |
| 15 | 21st request in one second from one IP to any route | HTTP/1.1 429 Too Many RequestsContent-Type: application/json{"error":{"code":"rate_limit_exceeded","message":"too many requests","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}(no Retry-After, no X-RateLimit-*) |
services/gateway/internal/ratelimit/ratelimit.go:70 |
Per-IP token bucket, in-process, keyed on RemoteAddr; X-Forwarded-For is deliberately not trusted |
| 16 | 11th request in one second to /auth/* |
HTTP/1.1 429 Too Many RequestsContent-Type: application/json{"error":{"code":"rate_limit_exceeded","message":"too many requests","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway/internal/ratelimit/ratelimit.go:70 |
Login bucket 5 rps / burst 10, applied before auth so junk tokens cost no signature check |
| 17 | any request whose handler panics | HTTP/1.1 500 Internal Server ErrorContent-Type: application/json{"error":{"code":"internal_error","message":"internal server error","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway/internal/obslog/logging.go:85 |
Recovery sits inside the log/metrics middleware, so a panicking request is still counted and logged with its real status |
2. Staff edge — gateway_dev, public listener :8080 (published 127.0.0.1:8090)¶
| # | Request | Response (today) | Handler | Why |
|---|---|---|---|---|
| 1 | GET /admin/ HTTP/1.1Host: <gateway_dev> |
HTTP/1.1 200 OKContent-Type: text/htmlCache-Control: no-cacheContent-Security-Policy: default-src 'self'; …X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: same-origin<!doctype html><html lang="en">… (the SPA shell, served by nginx; the four security headers come from services/adminui/security-headers.conf) |
services/gateway_dev/internal/router/dev.go:29 |
The bundle is not a secret; authorization lives on the API routes. The headers are added by the nginx location that serves the shell |
| 2 | POST /admin-auth/login HTTP/1.1Host: <gateway_dev>Content-Type: application/json{"email":"ada@otomo.internal","password":"hunter2"} |
HTTP/1.1 200 OKContent-Type: application/jsonSet-Cookie: __Host-otomo_refresh=…; Path=/; Max-Age=<refresh ttl>; HttpOnly; Secure; SameSite=Strict{"access_token":"…","expires_in":900,"user":{"id":"staff_7","name":"ada","roles":["live_ops"]}}or {"mfa_required":true,"mfa_ticket":"opaque-ticket"} / {"mfa_enrollment_required":true,"mfa_ticket":"opaque-ticket"}; bad credentials → 401 invalid_credentials |
services/gateway_dev/internal/router/dev.go:28 → services/admin_auth/internal/api/login.go:105 |
Unauthenticated by definition — it is the login surface. This is exactly why the whole listener is loopback-bound. The shared post-password policy turns the body into a session or a challenge |
| 3 | POST /admin-auth/refresh HTTP/1.1Host: <gateway_dev>Cookie: __Host-otomo_refresh=… |
HTTP/1.1 200 OKSet-Cookie: __Host-otomo_refresh=…; …Content-Type: application/json{"access_token":"…","expires_in":900,"user":{…}} |
services/gateway_dev/internal/router/dev.go:28 → services/admin_auth/internal/api/refresh.go:62 |
Set-Cookie is not hop-by-hop, so the proxy copies the response cookie back and the browser attributes it to this origin |
| 4 | GET /api/admin/config/namespaces HTTP/1.1Host: <gateway_dev>Authorization: Bearer <viewer staff jwt> |
HTTP/1.1 200 OKContent-Type: application/json{"namespaces":[{"name":"balance.weapons","audience":"client","description":"weapon balance","latest_version":11,"created_at":"2026-09-01T09:00:00Z","draft":{"revision":12,"updated_at":"2026-09-22T10:31:00Z","has_unpublished_changes":true}}]}(proxied to config; see §4 for every Config route) |
services/gateway_dev/internal/router/dev.go:30 → services/config/internal/api/namespaces.go:112 |
The subtree gate is viewer (SCRUM-200/D8). Config's own table is the finer one: reads are viewer, writes live_ops or admin |
| 5 | POST /api/admin/config/channels/live/releases HTTP/1.1Host: <gateway_dev>Authorization: Bearer <admin staff jwt>Content-Type: application/json{"base_release_id":40,"versions":[{"namespace":"balance.weapons","version":11}],"packs":[{"sha256":"9f2c…"}],"min_client_version":"1.4.0","message":"nerf the sword"} |
HTTP/1.1 201 CreatedContent-Type: application/json{"release_id":41,"channel":"live","manifest_sha256":"…","min_client_version":"1.4.0","message":"nerf the sword","created_by":"staff_7","created_at":"…","manifest":{…}} |
services/gateway_dev/internal/router/dev.go:31 → services/config/internal/api/releases.go:111 |
The literal path beats the subtree, so ServeMux enforces "live needs admin" with no handler if to forget |
| 6 | POST /api/admin/config/channels/live/releases HTTP/1.1Host: <gateway_dev>Authorization: Bearer <live_ops staff jwt>Content-Type: application/json{ …same body… } |
HTTP/1.1 403 ForbiddenContent-Type: application/json{"error":{"code":"insufficient_role","message":"the access token does not carry a sufficient role","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway_dev/internal/authn/middleware.go:44 |
Valid token, insufficient role → 403, and the client must not refresh: a new token would carry the same roles |
| 7 | GET /api/admin/dashboard/logs/tail?service=gateway HTTP/1.1Host: <gateway_dev>Authorization: Bearer <staff jwt>Accept: text/event-stream |
HTTP/1.1 200 OKContent-Type: text/event-streamCache-Control: no-storedata: {"at":"…","service":"gateway","level":"info","message":"…"} … (kept open) |
services/gateway_dev/internal/router/dev.go:37 → services/dashboard/internal/api/tail.go:121 |
SSE through a 30 s write timeout would be cut mid-stream, so the route clears the write deadline. Dashboard's tail handler is real now and fans the stream out of Loki |
| 8 | GET /api/admin/dashboard/overview HTTP/1.1Host: <gateway_dev>Authorization: Bearer <staff jwt> |
HTTP/1.1 200 OKContent-Type: application/json{"generated_at":"…","services":[{"name":"gateway","up":true,"ready":true,"reason":"","rps":12.4,"error_ratio":0.002,"p95_ms":38,"version":"staging"}],"host":{"cpu_ratio":0.31,"mem_ratio":0.62,"disk_ratio":0.44},"online_players":118,"degraded":[]} |
services/gateway_dev/internal/router/dev.go:38 → services/dashboard/internal/api/overview.go:101 |
The guard admits the caller at viewer; the handler queries Prometheus. Non-finite numbers serialise as JSON null, never NaN |
| 9 | GET /api/admin/session/players HTTP/1.1Host: <gateway_dev>Authorization: Bearer <staff jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway_dev/internal/router/dev.go:39 → services/session/internal/server/auth.go:29 → services/session/internal/api/stubs.go:19 |
One Session, two gateways, two prefixes, two domains — the edge decides who may reach it, the service re-checks. The 501 is Session's placeholder, reached only after both guards admitted the staff token |
| 10 | GET /api/player/session/me HTTP/1.1Host: <gateway_dev> |
HTTP/1.1 404 Not FoundContent-Type: application/json{"error":{"code":"not_found","message":"the requested path does not exist","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway_dev/gateway_dev.go:199 |
The player route table is not in this binary |
| 11 | GET /nope HTTP/1.1Host: <gateway_dev> |
HTTP/1.1 404 Not FoundContent-Type: application/json{"error":{"code":"not_found","message":"the requested path does not exist","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway_dev/gateway_dev.go:199 |
/ catch-all |
| 12 | GET /api/admin/users HTTP/1.1Host: <gateway_dev>(no Authorization) |
HTTP/1.1 401 UnauthorizedContent-Type: application/json{"error":{"code":"missing_token","message":"an access token is required","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway_dev/internal/authn/middleware.go:44 |
/api/admin/users and /api/admin/users/ are both registered, GroupStaff, MinRole: admin, SetForwarded: true — the collection root is added so ServeMux does not 307 before auth runs |
| 13 | GET /api/admin/users HTTP/1.1Host: <gateway_dev>Authorization: Bearer <live_ops staff jwt> |
HTTP/1.1 403 ForbiddenContent-Type: application/json{"error":{"code":"insufficient_role","message":"the access token does not carry a sufficient role","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway_dev/internal/authn/middleware.go:44 |
admin only at the edge; the service's requireAdmin repeats the DB role check as defence in depth |
| 14 | POST /api/admin/config/packs HTTP/1.1Host: <gateway_dev>Authorization: Bearer <live_ops staff jwt>Content-Type: application/octet-streamContent-Length: 48213904 |
HTTP/1.1 201 CreatedContent-Type: application/json{"pack_id":"…","name":"…","sha256":"9f2c…","size":48213904,"uploaded_by":"ada","uploaded_at":"…"} (or 200 when the same sha256 already exists) |
services/gateway_dev/internal/router/dev.go:36 → services/config/internal/api/packs.go:81 |
MaxBody: 512 << 20 and Upload: true: the read and write deadlines are cleared so a 512 MiB pack fits. Every route without MaxBody has the 1 MiB DefaultMaxBody |
| 15 | 11th /admin-auth/login in one second from one IP |
HTTP/1.1 429 Too Many RequestsContent-Type: application/json{"error":{"code":"rate_limit_exceeded","message":"too many requests","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway_dev/internal/ratelimit/ratelimit.go:70 |
/admin-auth/ gets the login bucket (default 5 rps / burst 10); the general bucket is 20 rps / burst 40 |
| 16 | 2 MiB JSON body to any route except a MaxBody/Upload route |
HTTP/1.1 413 Request Entity Too LargeContent-Type: application/json{"error":{"code":"body_too_large","message":"request body exceeds 1048576 bytes","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/gateway_dev/internal/proxy/proxy.go:23 |
per-route body caps (SCRUM-195/GWD-3); NewRegistry's proxy ErrorHandler is the func that writes the body_too_large 413. /admin/ static assets and the / catch-all are unlimited |
Limits on this edge (SCRUM-194/GWD-2, SCRUM-195/GWD-3): per-IP token buckets — general 20 rps /
burst 40, /admin-auth/ 5 rps / burst 10, /admin/ and the / 404 catch-all unlimited; default
body cap 1 MiB, POST /api/admin/config/packs 512 MiB with deadlines cleared. CORS is gone
(same-origin by design, SCRUM-201). There are still no retries.
3. auth — public listener :8080¶
| # | Request | Response | Handler | Why |
|---|---|---|---|---|
| 1 | GET /.well-known/jwks.json HTTP/1.1Host: auth:8080 |
HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: no-store{"keys":[{"kty":"OKP","crv":"Ed25519","kid":"auth-2026-09-27","x":"<base64url raw public key>","use":"sig","alg":"EdDSA"}]} |
services/auth/internal/api/jwks.go:23 |
Pre-marshalled from an atomic pointer at startup, from every active signing_key row, sorted by kid: no database access on the verify path. kid is whatever auth genkey -kid was given; the default is auth-<UTC date> (services/auth/auth.go:231). no-store because a copy cached past a rotation looks like a signing bug. The document is built once at boot; a key rotation needs a restart until rotation tooling exists |
| 2 | POST /auth/anonymous HTTP/1.1Host: auth:8080Content-Type: application/json{"device_id":"q3JqN1mG4c8u9xYwT0bVn5dKs2LpR7eHf6AzWiUoQyE"} |
HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: no-store{"schema_version":1,"access_token":"eyJhbGciOiJFZERTQSIs…","expires_in":900,"refresh_token":"n3Jq…","services":{"session":"http://localhost:8080/api/player/session","match":null}}or HTTP/1.1 400 Bad RequestContent-Type: application/json{"error":{"code":"validation_failed","message":"body must be {\"device_id\": \u003c22-128 characters of A-Z a-z 0-9 _ -\u003e}","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/auth/internal/api/anonymous.go:99 (store: services/auth/internal/store/accounts.go:30) |
device_id must match ^[A-Za-z0-9_-]{22,128}$; only its SHA-256 is stored. The same device_id always yields the same sub, or Session silently orphans that player's data. Each login starts a new refresh family |
| 3 | POST /auth/refresh HTTP/1.1Host: auth:8080Content-Type: application/json{"refresh_token":"n3JqN1mG4c8u9xYwT0bVn5dKs2LpR7eHf6AzWiUoQyE"} |
HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: no-store(row 2's body, with a new refresh_token)or HTTP/1.1 401 UnauthorizedContent-Type: application/json{"error":{"code":"invalid_token","message":"the refresh token is not valid; log in again","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}or HTTP/1.1 400 Bad Request … "code":"validation_failed" (body is not JSON) |
services/auth/internal/api/refresh.go:51 (store: services/auth/internal/store/refresh_tokens.go:74) |
POST because it rotates state. One 401 for every unusable token — missing, unknown, expired, revoked or reused — so it is no oracle. Reuse of an already-rotated token revokes the whole family. A 401 here means log in again, never refresh again |
| 4 | POST /auth/logout HTTP/1.1Host: auth:8080Content-Type: application/json{"refresh_token":"n3JqN1mG4c8u9xYwT0bVn5dKs2LpR7eHf6AzWiUoQyE"} |
HTTP/1.1 204 No Content |
services/auth/internal/api/refresh.go:123 (store: services/auth/internal/store/refresh_tokens.go:154) |
POST because it must revoke, not read. Revokes the token's whole family; 204 whatever the body held, so there is nothing to branch on. 500 internal_error only for a database failure |
| 5 | GET /auth/anonymous HTTP/1.1Host: auth:8080 (also /auth/refresh, /auth/logout) |
HTTP/1.1 405 Method Not AllowedAllow: POSTContent-Type: application/json{"error":{"code":"method_not_allowed","message":"method not allowed for this path","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/auth/internal/api/errors.go:75 |
Only the methods the table names. HEAD is not added here, unlike config/session, because auth's table is a plain list rather than a wildcard-resolved probe. Through the gateway the same 405 comes back, because /auth/ is proxied for every method |
| 6 | GET /metrics HTTP/1.1Host: auth:8080 |
HTTP/1.1 404 Not FoundContent-Type: application/json{"error":{"code":"not_found","message":"no such route","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/auth/internal/api/errors.go:75 |
Internal-only path: a public 404 does not advertise that it exists on :9090 |
| 7 | GET /healthz HTTP/1.1Host: auth:9090 |
HTTP/1.1 200 OKContent-Type: text/plain; charset=utf-8ok |
services/auth/internal/api/health.go:15 |
Liveness only — deliberately does not touch Postgres, or a database blip would get the container killed and restarted |
| 8 | GET /readyz HTTP/1.1Host: auth:9090 |
HTTP/1.1 200 OKContent-Type: text/plain; charset=utf-8okor HTTP/1.1 503 Service UnavailableContent-Type: application/json{"error":{"code":"not_ready","message":"postgres unreachable: dial tcp 10.0.0.4:5432: connect: connection refused","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/auth/internal/api/health.go:26 → services/auth/internal/server/server.go:278 |
Readiness = the startup flag + a Postgres ping cached for 10 s. The signing key is not re-checked here: serve refuses to start at all without a key file whose public half has an active signing_key row (services/auth/auth.go:123-139), so a running process always has one |
| 9 | GET /metrics HTTP/1.1Host: auth:9090 |
HTTP/1.1 200 OKContent-Type: text/plain; version=0.0.4; charset=utf-8# HELP auth_jwks_keys_active Active signing keys# TYPE auth_jwks_keys_active gaugeauth_jwks_keys_active 1auth_http_requests_total{method="GET",route="/.well-known/jwks.json",status="200"} 42 |
services/auth/internal/server/server.go:265 |
Unauthenticated and unpublished: the isolation of this listener is the whole access story |
| 10 | GET /debug/pprof/ HTTP/1.1Host: auth:9090 |
HTTP/1.1 200 OKContent-Type: text/html; charset=utf-8<html><head><title>/debug/pprof/</title></head><body>… |
services/auth/internal/server/server.go:266 |
pprof leaks more than it should if this listener is ever exposed |
| 11 | GET /nope HTTP/1.1Host: auth:8080 |
HTTP/1.1 404 Not FoundContent-Type: application/json{"error":{"code":"not_found","message":"no such route","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/auth/internal/api/errors.go:75 |
Anonymous service — even its fallback is unauthenticated |
4. config — public listener :8080 (staff only; prefix /api/admin/config)¶
Every route in Routes() (services/config/internal/api/routes.go) is implemented in
handlers.go's implemented() map, so the 501 placeholders are gone; role is the route
table's and is re-checked in-service. Query parameters and bodies shown are the ones the
handlers read. The schema PUT is the one route with a precondition: GET .../schema
returns the schema version as ETag, and the PUT requires it as If-Match (or
?base_version=), answering 428 precondition_required when neither is sent and
409 stale_schema when it disagrees.
| # | Request | Response | Handler | Role / Why |
|---|---|---|---|---|
| 1 | GET /api/admin/config/namespacesAuthorization: Bearer <staff jwt> |
200 OK{"namespaces":[{"name":"balance.weapons","audience":"client","description":"weapon balance","latest_version":11,"created_at":"2026-09-01T09:00:00Z","draft":{"revision":12,"updated_at":"2026-09-22T10:31:00Z","has_unpublished_changes":true}}]} |
services/config/internal/api/namespaces.go:112 |
viewer. Authoring entry screen in one call |
| 2 | POST /api/admin/config/namespaces{"name":"balance.weapons","audience":"client","description":"weapon balance"} |
201 Created with one namespace object; 409 already_exists |
services/config/internal/api/namespaces.go:134 |
admin: audience is fixed at creation and cannot change |
| 3 | GET /api/admin/config/namespaces/{ns}/schema |
200 OKETag: "3"{"namespace":"balance.weapons","schema_version":3,"schema":{"type":"object"},"created_by":"ada","created_at":"2026-09-01T09:00:00Z"} |
services/config/internal/api/schemas.go:52 |
viewer. The ETag is the version to send back as If-Match |
| 4 | PUT .../schemaIf-Match: "3"{"type":"object","properties":{"weapons":{"type":"array"}}} |
200 OK with the new schema object; 428 precondition_required when the precondition is absent; 409 stale_schema when it is stale; 400 validation_failed on an invalid JSON Schema or a weak/malformed If-Match |
services/config/internal/api/schemas.go:143 |
admin: creates a new schema_version; the version is the optimistic lock |
| 5 | GET .../draft |
200 OK{"namespace":"balance.weapons","document":{"weapons":[]},"revision":12,"base_version":11,"updated_by":"ada","updated_at":"2026-09-22T10:31:00Z"} |
services/config/internal/api/drafts.go:72 |
viewer. The draft is the only mutable row |
| 6 | PUT .../draft{"document":{"weapons":[{"id":"sword","damage":7}]},"revision":12} |
200 OK{"revision":13,"updated_at":"2026-09-22T10:33:00Z"}; 409 stale_revision |
services/config/internal/api/drafts.go:95 |
live_ops. Optimistic lock on revision |
| 7 | POST .../draft/validate{"document":{"weapons":[{"id":"sword","damage":"lots"}]}} |
200 OK{"valid":false,"schema_version":3,"errors":[{"pointer":"/weapons/0/damage","message":"expected integer, got string"}]} |
services/config/internal/api/draft_validate.go:40 |
live_ops. JSON pointers let the UI place each error |
| 8 | POST .../versions{"message":"nerf the sword","revision":12} |
201 Created{"namespace":"balance.weapons","version":12,"schema_version":3,"sha256":"…","size":123,"message":"nerf the sword","created_by":"ada","created_at":"…"}; 409 stale_revision / no_changes |
services/config/internal/api/versions.go:107 |
live_ops. Snapshot into an immutable version |
| 9 | GET .../versions?before=&limit= |
200 OK{"versions":[{"version":11,"schema_version":3,"sha256":"…","message":"nerf the sword","created_by":"ada","created_at":"…"}],"next_before":null} |
services/config/internal/api/versions.go:260 |
viewer |
| 10 | GET .../versions/{v} |
200 OK the version object plus "document" |
services/config/internal/api/versions.go:305 |
viewer |
| 11 | GET .../diff?from=10&to=draft |
200 OK{"from":{"ref":"10","document":…},"to":{"ref":"draft","document":…},"changes":[…]} |
services/config/internal/api/versions.go:348 |
viewer. "What am I about to publish" |
| 12 | POST /api/admin/config/packsContent-Type: application/octet-streamGDPC…<binary> |
201 Created (or 200 when the same sha256 already exists){"pack_id":"…","name":"…","sha256":"9f2c…","size":48213904,"uploaded_by":"ada","uploaded_at":"…"} |
services/config/internal/api/packs.go:81 |
live_ops. Streamed, never buffered; GDPC checked on the first 4 bytes |
| 13 | GET /api/admin/config/packs |
200 OK{"packs":[{"pack_id":"…","name":"…","sha256":"…","size":48213904,"uploaded_by":"ada","uploaded_at":"…"}]} |
services/config/internal/api/packs.go:148 |
viewer |
| 14 | POST .../channels/live/releases{"base_release_id":40,"versions":[{"namespace":"balance.weapons","version":11}],"packs":[{"sha256":"9f2c…"}],"min_client_version":"1.4.0","message":"nerf the sword"} |
201 Created{"release_id":41,"channel":"live","manifest_sha256":"…","min_client_version":"1.4.0","message":"nerf the sword","created_by":"ada","created_at":"…","manifest":{…}}; 409 stale_release |
services/config/internal/api/releases.go:111 |
admin — the literal path beats {ch} |
| 15 | POST .../channels/{ch}/releases (same body) |
201 Created with "channel":"staging" (or dev) |
services/config/internal/api/releases.go:111 |
live_ops |
| 16 | GET .../channels/{ch}/releases?before=&limit= |
200 OK{"head_release_id":41,"releases":[{"release_id":41,"manifest_sha256":"…","min_client_version":"1.4.0","message":"…","created_by":"ada","created_at":"…","is_head":true}],"next_before":null} |
services/config/internal/api/releases.go:255 |
viewer |
| 17 | POST .../channels/{ch}/rollback{"release_id":40,"base_release_id":41} |
200 OK release object; 409 stale_release / no_changes; 404 not_found |
services/config/internal/api/releases.go:317 |
admin: moves the channel pointer, i.e. changes live |
| 18 | POST .../channels/{ch}/promote?from=staging{"base_release_id":40,"message":"…"} |
200 OK release object; 400 validation_failed when from is not the lower channel |
services/config/internal/api/releases.go:394 |
admin: channels are a ladder |
| 19 | GET /api/admin/config/audit?action=&actor=&from=&to=&limit=&cursor= |
200 OK{"entries":[{"id":91,"at":"2026-09-22T10:29:00Z","actor_id":"staff_7","actor_name":"ada","source":"config","action":"release.publish","target":"live","details":{"release_id":41}}],"next_cursor":null} |
services/config/internal/api/audit.go:78 |
viewer. Shared audit shape, keyset cursor (cursor, not before) |
| 20 | POST /api/admin/config/nope |
404 not_found; without a token 401 missing_token |
services/config/internal/api/errors.go:95 (404) / services/config/internal/server/auth.go:28 (401) |
404s are authenticated too |
| 21 | DELETE /api/admin/config/namespaces |
405 method_not_allowed + Allow: GET, HEAD, POST (built by probing the real table) |
services/config/internal/api/errors.go:95 |
wildcards resolve; HEAD included for every GET |
| 22 | GET /metrics |
404 not_found |
services/config/internal/api/errors.go:95 |
internal listener only |
| 23 | GET /readyz |
200 ok, or 503 not_ready naming Postgres, the blob root and the staff JWKS |
services/config/internal/api/health.go:30 |
internal |
5. session — public listener :8080 (player and staff domains)¶
The route table is registered in full, but every route is still wired to
api.NotImplemented (services/session/internal/server/server.go), so each answers
501 not_implemented after the guard. The shapes below are the target spec. Compose does
not start this service, so through either gateway these requests answer 502 (§1/§2) and
this section is what the binary does when reached directly.
| # | Request | Response | Handler | Why |
|---|---|---|---|---|
| 1 | POST /api/player/session/me/init HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:72 → 501 placeholder |
Idempotent ON CONFLICT: ten concurrent first logins must produce exactly one profile |
| 2 | GET /api/player/session/me HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:73 → 501 placeholder |
— |
| 3 | PATCH /api/player/session/me HTTP/1.1Authorization: Bearer <player jwt>Content-Type: application/json{"display_name":"Tanuki"} |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}spec when renaming too soon: HTTP/1.1 429 Too Many Requests + {"error":{"code":"rate_limit_exceeded","message":"display name may change once every 24 hours","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:74 → 501 placeholder |
Names are unique on (lower(name), discriminator), so a rename is a scarce identity act, not a cosmetic edit |
| 4 | POST /api/player/session/presence/heartbeat HTTP/1.1Authorization: Bearer <player jwt>Content-Type: application/json{"status":"online"} |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}spec under 10 s since the last one: HTTP/1.1 429 Too Many Requests |
services/session/internal/api/routes.go:77 → 501 placeholder |
A 60 s lease refreshed every 20 s, ≤1 per 10 s. Highest-volume write in the service; ZCOUNT answers "how many online" in O(log n) |
| 5 | GET /api/player/session/friends HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}spec: HTTP/1.1 200 OK + {"friends":[{"player_id":"…","display_name":"Tanuki","discriminator":"4417","status":"online"}],"incoming":[{"player_id":"…","display_name":"Kitsune"}],"outgoing":[]} |
services/session/internal/api/routes.go:82 → 501 placeholder |
Friends plus both directions of pending in one call; presence for the whole list from a single Valkey pipeline |
| 6 | POST /api/player/session/friends/requests HTTP/1.1Authorization: Bearer <player jwt>Content-Type: application/json{"display_name":"Tanuki","discriminator":"4417"} |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}spec when throttled: HTTP/1.1 429 Too Many Requests |
services/session/internal/api/routes.go:83 → 501 placeholder |
Addressed by name+discriminator because the discriminator is what allows two players to share a display name |
| 7 | POST /api/player/session/friends/requests/<uuid>/accept HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:84 → 501 placeholder |
A pending friendship is identified by the pair, not by an id of its own |
| 8 | POST /api/player/session/friends/requests/<uuid>/decline HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:85 → 501 placeholder |
— |
| 9 | DELETE /api/player/session/friends/<uuid> HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:86 → 501 placeholder |
— |
| 10 | POST /api/player/session/blocks/<uuid> HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:87 → 501 placeholder |
One transaction: block + drop friendship + drop pending invites, so a block can never leave a live relationship behind |
| 11 | DELETE /api/player/session/blocks/<uuid> HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:88 → 501 placeholder |
— |
| 12 | POST /api/player/session/party HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}spec if already in a party: HTTP/1.1 409 Conflict |
services/session/internal/api/routes.go:92 → 501 placeholder |
The party_member primary key is the constraint, not a check-then-insert |
| 13 | GET /api/player/session/party HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}spec: HTTP/1.1 200 OK + {"party_id":"…","leader_id":"…","revision":57,"members":[{"player_id":"…","display_name":"Tanuki","status":"online"}]} |
services/session/internal/api/routes.go:93 → 501 placeholder |
revision lets a client throw away stale party.updated events |
| 14 | POST /api/player/session/party/invites HTTP/1.1Authorization: Bearer <player jwt>Content-Type: application/json{"player_id":"<uuid>"} |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:94 → 501 placeholder |
— |
| 15 | POST /api/player/session/party/invites/<invite_id>/accept HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:95 → 501 placeholder |
Locks the party row, checks invite + max_size, bumps the revision, and publishes after commit so a rolled-back join notifies nobody |
| 16 | POST /api/player/session/party/invites/<invite_id>/decline HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:96 → 501 placeholder |
— |
| 17 | POST /api/player/session/party/leave HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:97 → 501 placeholder |
A leader leaving promotes the longest-standing member — a party never ends up leaderless |
| 18 | POST /api/player/session/party/kick/<uuid> HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}spec if not the leader: HTTP/1.1 403 Forbidden |
services/session/internal/api/routes.go:98 → 501 placeholder |
Leader-only is a handler check against leader_id: no role expresses "leader of this party", and inventing one would make every party leader an admin |
| 19 | POST /api/player/session/party/promote/<uuid> HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:99 → 501 placeholder |
Same |
| 20 | GET /api/player/session/events?after=412 HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}spec, event available: HTTP/1.1 200 OK + [{"seq":413,"at":1789000000000,"type":"party.updated","payload":{"party_id":"…","revision":57}}]spec, hold expires: HTTP/1.1 200 OK + []spec, cursor too old: HTTP/1.1 200 OK + {"resync":true} |
services/session/internal/api/routes.go:103 → 501 placeholder |
Long-poll rather than WebSockets to keep the all-REST architecture. Events are hints — a missed one delays the UI but can never corrupt state. resync when the cursor is older than the 100-event / 10-minute window |
| 21 | GET /api/admin/session/players?name=Tanuki&discriminator=4417 HTTP/1.1Authorization: Bearer <staff jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:106 → 501 placeholder |
Staff route → verified against admin-auth's JWKS, never the player one. Lookup is the whole M1 moderation surface |
| 22 | GET /api/admin/session/players/<uuid> HTTP/1.1Authorization: Bearer <staff jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:107 → 501 placeholder |
— |
| 23 | POST /api/admin/session/parties/<uuid>/disband HTTP/1.1Authorization: Bearer <staff jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}}with a viewer token instead: HTTP/1.1 403 Forbidden + {"error":{"code":"insufficient_role","message":"the access token does not carry a sufficient role","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:113 → 501 placeholder |
live_ops: the only staff act that destroys player state → higher bar, and audited |
| 24 | GET /api/admin/session/audit HTTP/1.1Authorization: Bearer <staff jwt> |
HTTP/1.1 501 Not ImplementedContent-Type: application/json{"error":{"code":"not_implemented","message":"this endpoint is not implemented yet","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/routes.go:116 → 501 placeholder |
Read by staff and by Dashboard |
| 25 | GET /api/admin/session/players HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 401 UnauthorizedContent-Type: application/json{"error":{"code":"invalid_signature","message":"the access token is not valid","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/server/auth.go:29 |
A player token on a staff route: its kid is absent from the staff key set, so it fails before the issuer comparison |
| 26 | DELETE /api/player/session/me HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 405 Method Not AllowedAllow: GET, HEAD, PATCHContent-Type: application/json{"error":{"code":"method_not_allowed","message":"method not allowed for this path","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/errors.go:86 |
— |
| 27 | GET /nope HTTP/1.1(no Authorization) |
HTTP/1.1 401 UnauthorizedContent-Type: application/json{"error":{"code":"missing_token","message":"an access token is required","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/server/auth.go:29 |
The fallback handler is guarded too, so even a 404 needs a valid token — the surface is not enumerable anonymously |
| 28 | GET /nope HTTP/1.1Authorization: Bearer <player jwt> |
HTTP/1.1 404 Not FoundContent-Type: application/json{"error":{"code":"not_found","message":"no such route","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/errors.go:86 |
— |
| 29 | GET /readyz HTTP/1.1Host: session:9090 |
HTTP/1.1 200 OKContent-Type: text/plain; charset=utf-8okor HTTP/1.1 503 Service UnavailableContent-Type: application/json{"error":{"code":"not_ready","message":"valkey: dial tcp 10.0.0.5:6379: connect: connection refused; player jwks: no keys loaded","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/session/internal/api/health.go:30 |
Postgres + ValKey + both JWKS key counts |
6. patch — public listener :8080 (real handlers; services/patch/internal/api)¶
| # | Request | Response | Handler | Why |
|---|---|---|---|---|
| 1 | GET /patch/v1/live/manifestIf-None-Match: "9f2c…" |
304 Not ModifiedETag: "9f2c…"Cache-Control: no-cacheX-Min-Client-Version: 1.4.0(empty body) |
services/patch/internal/api/manifest.go:49 |
The manifest is the only thing that changes; validators go on 200 and 304 alike so an up-to-date client still learns X-Min-Client-Version |
| 2 | GET /patch/v1/live/manifest |
200 OKContent-Type: application/jsonETag: "9f2c…"Cache-Control: no-cacheX-Min-Client-Version: 1.4.0{"format":1,"channel":"live","release_id":41,"created_at":"…","min_client_version":"1.4.0","config":{"balance.weapons":{"version":11,"sha256":"…","size":1834}},"packs":[{"name":"nature_biome","sha256":"9f2c…","size":48213904}]} |
services/patch/internal/api/manifest.go:49 |
The body is the release's stored canonical manifest, re-derived and hash-checked at load. dev and staging use the same handler with their channel |
| 3 | GET /patch/v1/dev/manifest(no bearer, or a bad one) |
401 invalid_token (auth.MessageFor(reason)); with a valid staff bearer of role ≥ viewer → 200 as #2 |
services/patch/internal/api/manifest.go:49 |
live is public; dev/staging re-verify the staff token in the service even though the player gateway already gates them (defence in depth) |
| 4 | GET /patch/v1/prod/manifest |
404 not_found{"error":{"code":"not_found","message":"no such channel",…}} |
services/patch/internal/api/manifest.go:49 |
Closed channel set dev/staging/live; an unknown channel is not distinguishable from one the caller may not see |
| 5 | GET /patch/v1/blob/9f2c…Range: bytes=1048576- |
206 Partial ContentContent-Type: application/octet-streamContent-Range: bytes 1048576-48213903/48213904Cache-Control: public, max-age=31536000, immutableETag: "9f2c…"<binary> |
services/patch/internal/api/blob.go:42 |
http.ServeContent owns Range/If-Range/206/416/If-None-Match/304; nothing is buffered. Patch does not re-hash — the digest is the name |
| 6 | GET /patch/v1/blob/../../etc/passwd |
400 Bad Request{"error":{"code":"validation_failed","message":"sha256 must match ^[0-9a-f]{64}$","request_id":"0192f3a4-7c1e-7b3f-9a2d-4e5f6a7b8c9d"}} |
services/patch/internal/api/blob.go:92 |
The regex is the path-traversal guard, checked before any filesystem access; it also runs before ServeMux can clean and 307 the path |
Player gateway route table (services/gateway/internal/router/player.go): GET
/patch/v1/live/manifest is public; GET /patch/v1/dev/manifest and GET
/patch/v1/staging/manifest are GroupStaff (this is why the player gateway loads the staff
JWKS); GET /patch/v1/blob/ is GroupPublic with Stream: true, so the 30 s write deadline
is cleared for a large .pck download. The blob prefix is /patch/v1/blob/ — the earlier
/patch/v1/live/blob/ shape and its nesting were wrong.
7. dashboard — public listener :8080 (all six routes implemented)¶
services/dashboard/internal/api/routes.go registers six GET routes, each RoleViewer,
and handlers.go's For wires every one to a real handler. The overview and series
responses render a failed or non-finite number as JSON null, never NaN.
| # | Request | Response | Handler | Why |
|---|---|---|---|---|
| 1 | GET /api/admin/dashboard/overviewAuthorization: Bearer <staff jwt> |
200 OK{"generated_at":"…","services":[{"name":"gateway","up":true,"ready":true,"reason":"","rps":12.4,"error_ratio":0.002,"p95_ms":38,"version":"staging"}],"host":{"cpu_ratio":0.31,"mem_ratio":0.62,"disk_ratio":0.44},"online_players":118,"degraded":[]} |
services/dashboard/internal/api/overview.go:101 |
One Prometheus batch behind a 10 s response cache; a card whose query fails degrades to null and is named in degraded |
| 2 | GET /api/admin/dashboard/services |
200 OK{"services":[{"name":"gateway","up":true,"ready":true,"reason":"","latency_ms":3,"last_ready_at":"…","checked_at":"…"}]} |
services/dashboard/internal/api/services.go:23 |
The prober's snapshot, sorted by name; a Dashboard with no targets is an empty list, not a 501 |
| 3 | GET /api/admin/dashboard/services/{name}/series?metric=rps&from=&to=&step= |
200 OK columnar {"metric":"rps","title":"…","unit":"…","service":"gateway","from":…,"to":…,"step":…,"clamped":false,"t":[…],"series":[{"name":"2xx","values":[…]}]}; metric is a named template, never client PromQL; null for gaps |
services/dashboard/internal/api/series.go:79 |
step floors at 15 s, points cap at 1500, and a range wider than 7 days is clamped with "clamped":true |
| 4 | GET /api/admin/dashboard/logs?service=&level=&contains=&from=&to=&limit= |
200 OK{"entries":[{"at":"…","service":"gateway","level":"error","message":"…","fields":{…}}]}; contains escaped as a literal line filter, limit ≤ 1000 |
services/dashboard/internal/api/logs.go:76 |
The service must be in Loki's allow-list; a bad parameter is 400 validation_failed |
| 5 | GET /api/admin/dashboard/logs/tail?service=Accept: text/event-stream |
200 OKContent-Type: text/event-streamCache-Control: no-storedata: {"at":"…","service":"gateway","level":"info","message":"…"} … (kept open); per-user tail caps answer 429 too_many_tails |
services/dashboard/internal/api/tail.go:121 |
The stream route clears the write deadline at the gateway; keep-alives are comments |
| 6 | GET /api/admin/dashboard/audit?source=&cursor= |
200 OK{"entries":[{"id":91,"at":"…","actor_id":"staff_7","actor_name":"ada","source":"config","action":"release.publish","target":"live","details":{…}}],"next_cursor":null,"degraded":[]} |
services/dashboard/internal/api/audit.go:252 |
Fans out to Config and admin-auth, forwarding the caller's own bearer, and merges by timestamp; a failed feed is named in degraded |
8. admin-auth — public listener :8080 (real handlers; services/admin_auth)¶
Routes are those registered in internal/server/server.go; shapes come from the handlers in
internal/api. The request-ID envelope of §10 applies to every 4xx/5xx. The post-password
MFA policy (internal/api/session.go) is shared by login and onboarding: the break-glass
root gets a session; a confirmed factor gets {"mfa_required":true,"mfa_ticket":"…"}; an
admin with no factor gets {"mfa_enrollment_required":true,"mfa_ticket":"…"}.
| # | Request | Response | Handler | Why |
|---|---|---|---|---|
| 1 | GET /.well-known/jwks.json |
200 OKContent-Type: application/jsonCache-Control: no-store{"keys":[{"kty":"OKP","crv":"Ed25519","kid":"k1","x":"<base64url>","use":"sig","alg":"EdDSA"}]} |
services/admin_auth/internal/api/jwks.go:24 |
Pre-marshalled from an atomic pointer; fetched east-west, never proxied |
| 2 | POST /admin-auth/login{"email":"ada@otomo.internal","password":"hunter2"} |
200 OKSet-Cookie: __Host-otomo_refresh=…; Path=/; Max-Age=<refresh ttl>; HttpOnly; Secure; SameSite=Strict{"access_token":"…","expires_in":900,"user":{"id":"staff_7","name":"ada","roles":["live_ops"]}}or {"mfa_required":true,"mfa_ticket":"…"} / {"mfa_enrollment_required":true,"mfa_ticket":"…"}; bad credentials → 401 invalid_credentials "that email and password do not match an account"; bad/oversize body → 400 invalid_body |
services/admin_auth/internal/api/login.go:105 |
Unknown email verifies against a dummy hash; every credential failure is the same 401. Lockout after too many failures |
| 3 | POST /admin-auth/refreshCookie: __Host-otomo_refresh=… |
200 OK + rotated Set-Cookie + the same login body; unusable/absent/replayed cookie → 401 invalid_credentials "no valid refresh session" |
services/admin_auth/internal/api/refresh.go:62 |
The lookup takes the session row FOR UPDATE; a benign multi-tab grace window succeeds, a replay revokes the whole family |
| 4 | POST /admin-auth/logoutCookie: __Host-otomo_refresh=… |
204 No ContentSet-Cookie: otomo_refresh=; Path=/admin-auth/; Max-Age=-1; HttpOnly; Secure; SameSite=Strict |
services/admin_auth/internal/api/logout.go:21 |
Idempotent: no cookie or an unknown one still answers 204 |
| 5 | GET /admin-auth/meAuthorization: Bearer <staff jwt> |
200 OK{"id":"staff_7","name":"ada","roles":["live_ops"],"is_root":false,"mfa_enabled":true}; bad token → 401 <verifier reason> |
services/admin_auth/internal/api/me.go:38 |
Verifies in-process, then reads name/roles/is_root/mfa_enabled from the database, so a demoted or disabled account stops resolving |
| 6 | POST /admin-auth/onboard/lookup{"token":"<t>"} |
200 OK{"purpose":"invite","email":"…","name":"…","role":"viewer","expires_at":"…"} ("role":null for a reset); anything unusable → 404 invalid_link |
services/admin_auth/internal/api/onboard.go:80 |
The token arrives in the URL fragment and is POSTed, so it never reaches an access log |
| 7 | POST /admin-auth/onboard{"token":"<t>","password":"<new>"} |
200 OK (session or {mfa_required|mfa_enrollment_required, mfa_ticket}) + refresh cookie; 400 validation_failed on the password policy; 409 already_exists; 404 invalid_link |
services/admin_auth/internal/api/onboard.go:119 |
One transaction consumes the link and opens a session, exactly like login; the same MFA policy runs |
| 8 | POST /admin-auth/mfa/verify{"mfa_ticket":"…","code":"123456"} |
200 OK + login body + rotated Set-Cookie; 400 validation_failed on empty fields; 401 invalid_ticket / 401 invalid_code |
services/admin_auth/internal/api/mfa.go:111 |
Spends an attempt on the ticket before checking the code; a valid TOTP or a recovery code both resolve |
| 9 | POST /admin-auth/mfa/enroll{"mfa_ticket":"…"} (ticket) or Authorization: Bearer <staff jwt> (bearer) |
200 OK{"secret":"<base32>","otpauth_url":"otpauth://totp/…"}; root → 403 mfa_not_allowed; already enrolled → 409 mfa_already_enabled |
services/admin_auth/internal/api/mfa.go:249 |
Starts (or restarts) TOTP enrollment for the ticket's account or the bearer's own account |
| 10 | POST /admin-auth/mfa/confirm{"mfa_ticket":"…","code":"123456"} (or bearer) |
200 OK{"recovery_codes":[…],"access_token":"…","expires_in":900,"user":{…}} when a ticket is present, otherwise just the codes; 409 mfa_not_enrolling / mfa_already_enabled; 401 invalid_code |
services/admin_auth/internal/api/mfa.go:306 |
Proves the first TOTP and returns ten recovery codes; with a ticket it also adopts the session |
| 11 | GET /admin-auth/audit?action=&actor=&from=&to=&limit=&cursor= |
200 OK{"entries":[{"id":91,"at":"…","actor_id":"staff_7","actor_name":"ada","source":"admin-auth","action":"login.success","target":"…","details":{…}}],"next_cursor":null}; 400 validation_failed on a malformed cursor/time/limit |
services/admin_auth/internal/api/audit.go:97 |
Wrapped in requireRole("viewer"); the read side Dashboard's fan-out consumes |
| 12 | POST /admin-auth/account/password{"current_password":"…","new_password":"…"} |
204 No Content (revokes every other refresh family); 400 validation_failed; 401 invalid_credentials |
services/admin_auth/internal/api/account.go:182 |
Bearer-only; the current password is re-verified before the new one is written |
| 13 | GET /admin-auth/account/sessions |
200 OK{"sessions":[{"id":"…","created_at":"…","last_used_at":"…","ip":"…","user_agent":"…","current":true}]} |
services/admin_auth/internal/api/account.go:246 |
The signed-in account's live refresh families, current one flagged |
| 14 | POST /admin-auth/account/sessions/revoke-others |
200 OK{"revoked":3,"current_kept":true} |
services/admin_auth/internal/api/account.go:276 |
Revokes every family except the caller's |
| 15 | POST /admin-auth/account/mfa/recovery-codes{"code":"…"} |
200 OK{"recovery_codes":[…]}; 409 mfa_not_enrolled when there is no factor; 401 invalid_code |
services/admin_auth/internal/api/account.go:296 |
Replaces the ten codes after proving a current TOTP or recovery code |
| 16 | POST /admin-auth/account/mfa/disable{"code":"…"} |
204 No Content; 403 mfa_required for an admin, 403 root_protected for root; 409 mfa_not_enrolled; 401 invalid_code |
services/admin_auth/internal/api/account.go:360 |
Only a non-admin may turn the factor off |
| 17 | GET /api/admin/usersAuthorization: Bearer <admin staff jwt> |
200 OK{"users":[{"id":…,"email":…,"name":…,"roles":[…],"is_root":false,"status":"active","mfa_enrolled":true,"last_login_at":"…","created_at":"…"}]} |
services/admin_auth/internal/api/admin_users.go:229 |
Lists accounts; never a hash or a TOTP secret. requireAdmin re-checks the DB role |
| 18 | POST /api/admin/users{"email":"…","name":"…","role":"viewer"} |
201 Created{"invite_id":"…","email":"…","role":"viewer","invite_url":"http://…/admin/onboard#token=…","expires_at":"…"}; 409 already_exists/invite_pending; 403 insufficient_role when a non-root grants admin |
services/admin_auth/internal/api/admin_users.go:247 |
Link token in the fragment, returned once |
| 19 | GET /api/admin/users/invites |
200 OK{"invites":[{"id":…,"purpose":"invite","email":…,"name":…,"role":…,"created_by":…,"created_at":…,"expires_at":…}]} |
services/admin_auth/internal/api/admin_users.go:312 |
Pending invites and resets, never their tokens |
| 20 | DELETE /api/admin/users/invites/{id} |
204 No Content; unknown/used/revoked → 404 not_found |
services/admin_auth/internal/api/admin_users.go:338 |
Revokes one pending link |
| 21 | PATCH /api/admin/users/{id}{"roles":["live_ops"]} or {"status":"disabled"} |
200 OK one user object; 400 validation_failed; 403 root_protected/self_modification/insufficient_role; 404 not_found |
services/admin_auth/internal/api/admin_users.go:361 |
D3: only root may grant/change admin, nobody edits self, root is CLI-only; disabling revokes sessions |
| 22 | POST /api/admin/users/{id}/reset |
201 Created{"invite_id":"…","reset_url":"http://…/admin/onboard#token=…","expires_at":"…"}; 403 root_protected/insufficient_role; 404 not_found |
services/admin_auth/internal/api/admin_users.go:434 |
Reset links share the onboarding page |
| 23 | POST /api/admin/users/{id}/mfa/reset |
200 OK updated user object; 409 mfa_not_enrolled when there is no factor; 403 root_protected/insufficient_role; 404 not_found |
services/admin_auth/internal/api/admin_users.go:483 |
Clears the target's TOTP secret, recovery codes and sessions (SCRUM-262) |
/admin-auth/* is GroupPublic on gateway_dev with SetForwarded: true; /api/admin/users
and /api/admin/users/ are GroupStaff, MinRole: admin, SetForwarded: true, and the
service's requireAdmin re-checks the database role. All other paths on the public listener
get the COM-5 404/405.
9. adminui — packets the browser sends¶
baseUrl comes from /admin/env.json ("" = same origin, i.e. gateway_dev). Every
request carries Accept: application/json and credentials: same-origin, plus
Authorization: Bearer <access token> unless it is one of the auth calls. For this section
the Handler column names the TS client function that issues the call, in
services/adminui/src/api/.
| # | Request | Response the UI reads | Handler |
|---|---|---|---|
| 1 | GET /admin/env.json HTTP/1.1Host: <gateway_dev> |
HTTP/1.1 200 OKCache-Control: no-cache{"apiBaseUrl":"","appTitle":"Otomo Admin","environment":"live","auth":{"loginPath":"/admin-auth/login","mePath":"/admin-auth/me","refreshPath":"/admin-auth/refresh","logoutPath":"/admin-auth/logout","mfaPath":"/admin-auth/mfa/verify","onboardLookupPath":"/admin-auth/onboard/lookup","onboardRedeemPath":"/admin-auth/onboard","mfaEnrollPath":"/admin-auth/mfa/enroll","mfaConfirmPath":"/admin-auth/mfa/confirm"}} |
services/adminui/src/api/env.ts:115 |
| 2 | POST /admin-auth/login HTTP/1.1Content-Type: application/jsoncredentials: same-origin{"email":"ada@otomo.internal","password":"hunter2"} |
{"access_token":"…","expires_in":3600,"user":{"id":"staff_7","name":"ada","roles":["live_ops"]}} or {"mfa_required":true,"mfa_ticket":"opaque-ticket"} or {"mfa_enrollment_required":true,"mfa_ticket":"…"} — a 200 matching none of them becomes ApiError{status:200, code:"internal"} |
services/adminui/src/api/auth.ts:151 |
| 3 | POST /admin-auth/onboard/lookup HTTP/1.1Content-Type: application/json{"token":"<fragment token>"} |
{"purpose":"invite","email":"…","name":"…","role":"viewer","expires_at":"…"} (role:null for a reset) |
services/adminui/src/api/auth.ts:164 |
| 4 | POST /admin-auth/onboard HTTP/1.1Content-Type: application/json{"token":"<t>","password":"<new>"} |
the same three-way outcome as login #2 | services/adminui/src/api/auth.ts:191 |
| 5 | POST /admin-auth/mfa/enroll HTTP/1.1Content-Type: application/json{"mfa_ticket":"…"} (challenge) or {} with bearer (account page) |
{"secret":"<base32>","otpauth_url":"otpauth://totp/…"} |
services/adminui/src/api/auth.ts:201 |
| 6 | POST /admin-auth/mfa/confirm HTTP/1.1Content-Type: application/json{"mfa_ticket":"…","code":"123456"} (or {"code":"…"} with bearer) |
{"recovery_codes":[…],"access_token":"…","expires_in":3600,"user":{…}}; the client keeps session only when the access token is present |
services/adminui/src/api/auth.ts:219 |
| 7 | POST /admin-auth/mfa/verify HTTP/1.1Content-Type: application/json{"mfa_ticket":"opaque-ticket","code":"123456"} |
{"access_token":"…","expires_in":3600,"user":{"id":"staff_7","name":"ada","roles":["live_ops"]}} |
services/adminui/src/api/auth.ts:233 |
| 8 | POST /admin-auth/refresh HTTP/1.1credentials: same-origin(no body, no bearer) |
{"access_token":"…","expires_in":3600,"user":{…}} — also fired once per page load by the router guard, before the first protected call |
services/adminui/src/api/auth.ts:246 |
| 9 | POST /admin-auth/logout HTTP/1.1credentials: same-origin |
HTTP/1.1 204 No Content; the UI ignores the body and clears local state even if this fails |
services/adminui/src/api/auth.ts:256 |
| 10 | GET /admin-auth/me HTTP/1.1Authorization: Bearer <access token> |
{"id":"staff_7","name":"ada","roles":["live_ops"],"is_root":false,"mfa_enabled":true} — parsed only for is_root and mfa_enabled |
services/adminui/src/api/account.ts:64 |
| 11 | POST /admin-auth/account/password HTTP/1.1Authorization: Bearer <access token>{"current_password":"…","new_password":"…"} |
204 No Content; the page refreshes the session list afterwards |
services/adminui/src/api/account.ts:74 |
| 12 | GET /admin-auth/account/sessions HTTP/1.1Authorization: Bearer <access token> |
{"sessions":[{"id":…,"created_at":…,"last_used_at":…,"ip":…,"user_agent":…,"current":true}]} |
services/adminui/src/api/account.ts:82 |
| 13 | POST /admin-auth/account/sessions/revoke-others HTTP/1.1Authorization: Bearer <access token> |
{"revoked":3,"current_kept":true} |
services/adminui/src/api/account.ts:95 |
| 14 | POST /admin-auth/account/mfa/recovery-codes HTTP/1.1Authorization: Bearer <access token>{"code":"…"} |
{"recovery_codes":[…]}; 409 mfa_not_enrolled when there is no factor |
services/adminui/src/api/account.ts:105 |
| 15 | POST /admin-auth/account/mfa/disable HTTP/1.1Authorization: Bearer <access token>{"code":"…"} |
204 No Content; 403 mfa_required for an admin, 403 root_protected for root |
services/adminui/src/api/account.ts:124 |
| 16 | POST /admin-auth/mfa/enroll HTTP/1.1Authorization: Bearer <access token>{} |
{"secret":"…","otpauth_url":"…"} |
services/adminui/src/api/account.ts:129 |
| 17 | POST /admin-auth/mfa/confirm HTTP/1.1Authorization: Bearer <access token>{"code":"…"} |
{"recovery_codes":[…]} (bearer mode has no session) |
services/adminui/src/api/account.ts:146 |
| 18 | GET /api/admin/users HTTP/1.1Authorization: Bearer <access token> |
{"users":[{id,email,name,roles,is_root,status,mfa_enrolled,last_login_at,created_at}]} |
services/adminui/src/api/users.ts:112 |
| 19 | GET /api/admin/users/invites HTTP/1.1Authorization: Bearer <access token> |
{"invites":[{id,purpose,email,name,role,created_by,created_at,expires_at}]} |
services/adminui/src/api/users.ts:117 |
| 20 | POST /api/admin/users HTTP/1.1Authorization: Bearer <access token>{"email":"…","name":"…","role":"viewer"} |
{"invite_id":"…","invite_url":"http://…/admin/onboard#token=…","expires_at":"…"} — the link is shown once |
services/adminui/src/api/users.ts:126 |
| 21 | DELETE /api/admin/users/invites/{id} HTTP/1.1Authorization: Bearer <access token> |
204 No Content |
services/adminui/src/api/users.ts:132 |
| 22 | PATCH /api/admin/users/{id} HTTP/1.1Authorization: Bearer <access token>{"roles":["live_ops"]} or {"status":"disabled"} |
one user object (same shape as #18) | services/adminui/src/api/users.ts:137 |
| 23 | POST /api/admin/users/{id}/reset HTTP/1.1Authorization: Bearer <access token> |
{"invite_id":"…","reset_url":"http://…/admin/onboard#token=…","expires_at":"…"} |
services/adminui/src/api/users.ts:143 |
| 24 | POST /api/admin/users/{id}/mfa/reset HTTP/1.1Authorization: Bearer <access token> |
one user object; 409 mfa_not_enrolled when there was no factor |
services/adminui/src/api/users.ts:154 |
| 25 | GET /api/admin/config/namespaces HTTP/1.1Authorization: Bearer <access token> |
{"namespaces":[{"name":"balance.weapons","audience":"client","description":"weapon balance","latest_version":11,"draft":{"revision":12,"updated_at":"2026-09-22T10:31:00Z","has_unpublished_changes":true}}]} |
services/adminui/src/api/config.ts:94 |
| 26 | POST /api/admin/config/namespaces HTTP/1.1Authorization: Bearer <access token>Content-Type: application/json{"name":"balance.weapons","audience":"client","description":"weapon balance"} |
201 Created one namespace object ({"name":…,"audience":…,"description":…,"latest_version":…,"draft":{"revision":…,"updated_at":…,"has_unpublished_changes":…}}); 409 already_exists / 400 validation_failed |
services/adminui/src/api/config.ts:114 |
| 27 | GET /api/admin/config/namespaces/balance.weapons/schema HTTP/1.1Authorization: Bearer <access token> |
{"namespace":"balance.weapons","schema_version":3,"schema":{"type":"object"},"created_by":"ada","created_at":"2026-09-01T09:00:00Z"}; the ETag is kept as the next If-Match |
services/adminui/src/api/config.ts:121 |
| 28 | PUT .../schema HTTP/1.1Authorization: Bearer <access token>If-Match: "3"{"type":"object",…} |
200 OK new schema object; 409 stale_schema (isStaleSchema) re-reads and opens the conflict dialog; 428 precondition_required when the tag is missing |
services/adminui/src/api/config.ts:141 |
| 29 | GET /api/admin/config/namespaces/balance.weapons/draft HTTP/1.1Authorization: Bearer <access token> |
{"namespace":"balance.weapons","document":{"weapons":[]},"revision":12,"base_version":11,"updated_by":"ada","updated_at":"…"} |
services/adminui/src/api/config.ts:171 |
| 30 | PUT /api/admin/config/namespaces/balance.weapons/draft HTTP/1.1Authorization: Bearer <access token>{"document":{"weapons":[{"id":"sword","damage":7}]},"revision":12} |
{"revision":13,"updated_at":"…"}; 409 stale_revision (isStaleRevision) re-reads the draft |
services/adminui/src/api/config.ts:192 |
| 31 | POST /api/admin/config/namespaces/balance.weapons/draft/validate HTTP/1.1Authorization: Bearer <access token>{"document":{"weapons":[{"id":"sword","damage":"lots"}]}} |
{"valid":false,"errors":[{"pointer":"/weapons/0/damage","message":"expected integer, got string"}]} — validation runs before every save |
services/adminui/src/api/config.ts:375 |
| 32 | GET /api/admin/config/namespaces/ui.presentation/draft HTTP/1.1Authorization: Bearer <access token> |
same shape as #29; every failure is swallowed and the form falls back to schema order, which is why ui.presentation is allowed to be a different, incompatible schema |
services/adminui/src/api/config.ts:171 |
| 33 | GET /api/admin/config/namespaces/balance.weapons/versions?before=11&limit=10 HTTP/1.1Authorization: Bearer <access token> |
{"versions":[{"version":11,"schema_version":3,"sha256":"…","message":"…","created_by":"…","created_at":"…"}],"next_before":null} |
services/adminui/src/api/config.ts:265 |
| 34 | GET /api/admin/config/namespaces/balance.weapons/versions/11 HTTP/1.1Authorization: Bearer <access token> |
200 OK the version object plus "size" and "document" |
services/adminui/src/api/config.ts:281 |
| 35 | POST /api/admin/config/namespaces/balance.weapons/versions HTTP/1.1Authorization: Bearer <access token>Content-Type: application/json{"message":"nerf the sword","revision":12} |
201 Created{"version":12,"schema_version":3,"sha256":"…","message":"nerf the sword","created_by":"ada","created_at":"…"}; 409 stale_revision / no_changes; 400 validation_failed |
services/adminui/src/api/config.ts:299 |
| 36 | GET /api/admin/config/namespaces/balance.weapons/diff?from=10&to=draft HTTP/1.1Authorization: Bearer <access token> |
{"from":{"ref":"10","document":…},"to":{"ref":"draft","document":…},"changes":[{"op":"replace","path":"/weapons/0/damage","from":7,"to":9}]} |
services/adminui/src/api/config.ts:363 |
| 37 | GET /api/admin/config/channels/live/releases?before=42&limit=10 HTTP/1.1Authorization: Bearer <access token> |
{"head_release_id":41,"releases":[{"release_id":41,"manifest_sha256":"…","min_client_version":"1.4.0","message":"…","created_by":"ada","created_at":"…","is_head":true,"manifest":{…}}],"next_before":null} |
services/adminui/src/api/config.ts:527, services/adminui/src/api/config.ts:544 (same request; the second returns just .releases) |
| 38 | GET /api/admin/config/channels/live/releases?before=42&limit=1 HTTP/1.1Authorization: Bearer <access token> |
one release row as #37, narrowed to the requested release_id; an absent row makes the client throw its own 404 not_found |
services/adminui/src/api/config.ts:557 |
| 39 | GET /api/admin/config/channels/live/releases?before=41&limit=1 HTTP/1.1Authorization: Bearer <access token> |
the release immediately below the id, or null when it is the first |
services/adminui/src/api/config.ts:572 |
| 40 | GET /api/admin/config/channels/live/releases?limit=1 HTTP/1.1Authorization: Bearer <access token> |
{"head_release_id":41,"releases":[one row],"next_before":null}; when the head is not in that page the client issues a second read (?before=<head+1>&limit=1) for it |
services/adminui/src/api/config.ts:595 |
| 41 | POST /api/admin/config/channels/live/releases HTTP/1.1Authorization: Bearer <access token>Content-Type: application/json{"base_release_id":40,"versions":[{"namespace":"balance.weapons","version":11}],"packs":[{"sha256":"9f2c…"}],"min_client_version":"1.4.0","message":"nerf the sword"} |
201 Created release object with "manifest"; 409 stale_release (isStaleRelease); 400 validation_failed |
services/adminui/src/api/config.ts:629 |
| 42 | POST /api/admin/config/channels/live/rollback HTTP/1.1Authorization: Bearer <access token>Content-Type: application/json{"release_id":40,"base_release_id":41} |
200 OK release object; 409 stale_release / no_changes; 404 not_found |
services/adminui/src/api/config.ts:655 |
| 43 | POST /api/admin/config/channels/live/promote?from=staging HTTP/1.1Authorization: Bearer <access token>Content-Type: application/json{"base_release_id":40,"message":"…"} |
200 OK release object; 409 stale_release / no_changes; 400 validation_failed |
services/adminui/src/api/config.ts:675 |
| 44 | GET /api/admin/config/packs HTTP/1.1Authorization: Bearer <access token> |
{"packs":[{"pack_id":"…","name":"…","sha256":"…","size":48213904,"uploaded_by":"ada","uploaded_at":"…"}]} |
services/adminui/src/api/config.ts:715 |
| 45 | POST /api/admin/config/packs?name=weapons.pck HTTP/1.1Authorization: Bearer <access token>Content-Type: application/octet-streamGDPC…<binary bytes> |
201 Created new pack (or 200 OK when the same sha256 already exists){"pack_id":"…","name":"…","sha256":"9f2c…","size":48213904,"uploaded_by":"ada","uploaded_at":"…"}; the XHR upload.onprogress feed drives the progress bar |
services/adminui/src/api/config.ts:739 |
| 46 | GET /api/admin/dashboard/overview HTTP/1.1Authorization: Bearer <access token> |
{"generated_at":"…","services":[…],"host":{…},"online_players":118,"degraded":[]} — a missing or non-finite number is null, never NaN |
services/adminui/src/api/dashboard.ts:224 |
| 47 | GET /api/admin/dashboard/services HTTP/1.1Authorization: Bearer <access token> |
{"services":[{"name":"gateway","up":true,"ready":true,"reason":"","latency_ms":3,"last_ready_at":"…","checked_at":"…"}]} — in the Logs view a failure here is swallowed and the filter falls back to "every service" |
services/adminui/src/api/dashboard.ts:248 |
| 48 | GET /api/admin/dashboard/services/gateway/series?metric=rps&from=1789000000&to=1789086400&step=15 HTTP/1.1Authorization: Bearer <access token> |
{"metric":"rps","title":"…","unit":"…","service":"gateway","from":1789000000,"to":1789086400,"step":15,"clamped":false,"t":[…],"series":[{"name":"2xx","values":[1,null,…]}]} |
services/adminui/src/api/dashboard.ts:320 |
| 49 | GET /api/admin/dashboard/logs?service=gateway&level=error&contains=&from=&to=&limit=500 HTTP/1.1Authorization: Bearer <access token> |
{"entries":[{"at":"…","service":"gateway","level":"error","message":"…","fields":{…}}]} |
services/adminui/src/api/dashboard.ts:253 |
| 50 | GET /api/admin/dashboard/logs/tail?service=gateway HTTP/1.1Authorization: Bearer <access token>Accept: application/json (not text/event-stream — the content type is never asserted) |
data: {"at":"…","service":"gateway","level":"info","message":"…"} frames, parsed by hand and normalised to {at, service, level, message}; a non-JSON frame is wrapped as {message:<raw>}. Reconnect backoff 500 ms → 10 s |
services/adminui/src/api/sse.ts:111 |
| 51 | GET /api/admin/dashboard/audit?source=config&actor=&from=&to=&cursor=eyJvIjo5MX0 HTTP/1.1Authorization: Bearer <access token> |
{"entries":[{id,at,actor_id,actor_name,source,action,target,details}],"next_cursor":null,"degraded":[]} |
services/adminui/src/api/dashboard.ts:267 |
Behaviour worth knowing: there is no setInterval anywhere — the overview loads on
mount and on a Refresh button only. expires_in is stored as expiresAt and never
read; refresh is purely 401-driven. On a 401 the session is cleared and the error
renders — the redirect to /login happens on the next navigation, through the router
guard, not in response to the 401 itself. The access token lives in memory only, and
the refresh token is an httpOnly cookie the SPA never sees. /admin/ responses carry
the four security headers from services/adminui/security-headers.conf.
10. The layer every row shares¶
Error envelope. Every 4xx/5xx from a Go service in this system is exactly this shape, and no handler builds an error body by hand:
| Status | Codes | Client action |
|---|---|---|
| 401 | missing_token, invalid_signature, expired, iss_mismatch, aud_mismatch, invalid_token, invalid_credentials, invalid_ticket, invalid_code |
refresh once, retry once — except a 401 from POST /auth/refresh itself, which means log in again via /auth/anonymous and never refresh again |
| 403 | insufficient_role, root_protected, self_modification, mfa_required, mfa_not_allowed |
refresh cannot help — show the refusal |
| 404 / 405 | not_found / method_not_allowed (+Allow); invalid_link on the onboarding lookups |
— |
| 409 | already_exists, invite_pending, stale_revision, stale_schema, stale_release, no_changes, mfa_already_enabled, mfa_not_enrolled, mfa_not_enrolling |
re-read the resource and retry deliberately |
| 400 / 428 | validation_failed, invalid_body, precondition_required (428), body_too_large (413) |
fix the request |
| 429 | rate_limit_exceeded, too_many_tails |
back off |
| 500 / 501 / 502 / 503 | internal_error / not_implemented / upstream_error / not_ready |
— |
401 versus 403 is the load-bearing split: a 403 that triggers a refresh is an infinite
loop, which is the exact bug this taxonomy exists to prevent. The verifier's reason
strings — missing_token, invalid_signature, expired, iss_mismatch,
aud_mismatch, invalid_token, insufficient_role — are the 401/403 codes directly;
a service never invents a different spelling for a token failure.
The auth check, in this order, on every protected route: EdDSA only → signature
and kid → exp (required, 30 s leeway) → iss → aud → non-empty sub → role ≥
the route's minimum. iss and aud are read from the token, never inferred from
which JWKS answered. JWKS clients: 5 s fetch timeout, 30 s refresh, re-fetch on an
unknown kid, last-good keys survive a failed refresh, and fail closed with zero
keys.
Two domains, never mixed: player (https://auth.otomo.internal / otomo:player / no
roles) and staff (https://admin-auth.otomo.internal / otomo:staff / roles[],
viewer < live_ops < admin). Both sign EdDSA on purpose — one algorithm means one
validator, one Claims struct, and no per-domain branch to get wrong.
What a proxy hop does: the path is forwarded unstripped; Authorization passes
through untouched; Host becomes the upstream's host; X-Forwarded-For is appended
(the client's value is preserved and explicitly untrusted); X-Forwarded-Host and
X-Forwarded-Proto are never set; an upstream 4xx/5xx is passed through unchanged; a
connection failure becomes HTTP/1.1 502 Bad Gateway; there are no retries.
Route resolution: Go 1.22 ServeMux — a literal segment beats a wildcard, a
trailing slash is a subtree, and /foo answers HTTP/1.1 301 Moved Permanently to
/foo/ when only the subtree exists. A wrong method on a gateway is a 404 (the
method-less / catch-all matches it); on a service it is a 405 with Allow.
The ten decisions these packets encode¶
- Two gateway binaries, not one with a flag — reachability must not be a runtime env var on an internet-facing container.
- Every backend re-verifies — the private network is not an authorization decision.
- Two identity domains, EdDSA both — one validator, one Claims struct, no algorithm-substitution hole.
- Routes registered before handlers exist — a 501 proves the guard admitted the caller, which is how the domain boundaries are tested today. Session is the last table still in that state.
- Paths unstripped — one path space; the SPA lives at
/admin/to match. Stream: trueper route — clearing the write deadline is a reduction in protection and must not be global.- Long-poll, not WebSockets — all-REST, and a missed event delays the UI but cannot corrupt state.
- Immutability and content addressing — an update check is one request that usually answers
304. - Live publish needs
admin, expressed in the route table — noif channel == "live"to forget. - Fail fast at boot — missing upstream, unset JWKS, equal issuers,
hold ≥ read timeout⇒ exit, not a half-configured process.
Gaps¶
Status per the plan's §7 Jira mapping and this tree's code. "Closed" means the code shows it; "open" means it is outside this change or still in review.
- Closed — SCRUM-200 (GWD-1/D8).
dev.gogates/api/admin/config/atRoleViewer; Config's own table keeps writes atlive_ops/admin. - Closed — SCRUM-240 (OPS-1).
deploy/compose.yamlnow definespostgres,valkey, the observability stack,auth,admin-auth,config,patch,dashboard,admin-ui,gatewayandgateway_dev.sessionjoined compose in SCRUM-268 (session-migrate, thensession);allocatorandmatchmakerare still deliberately absent. - Closed in code (services exist and are in compose) — SCRUM-223…227, SCRUM-232…239.
patchanddashboardare inservices/with real handlers/route tables and are wired into compose. Dashboard's six routes are no longer 501 placeholders. - Closed — SCRUM-200. The staff issuer is pinned to
https://admin-auth.otomo.internalin code and compose; thephp-adminspelling is gone. - Closed — SCRUM-195 (GWD-3). Per-route
MaxBodywithUploadclears the read and write deadlines for a 512 MiB pack; stream routes clear the write deadline. The old "35 s read timeout" wording is retired by the route-level deadlines. - Open — out of scope (§6).
docs/04-session-minimal.md§5 still omitsGET /api/admin/session/audit; the session admin surface is not part of this change. - Closed — SCRUM-209 (AA-10).
GET /admin-auth/auditis registered inserver.goand implemented ininternal/api/audit.go; Dashboard's fan-out consumes it. - Closed — SCRUM-84. The hand-off payload follows
docs/06§12:schema_versionat the top level of the login and refresh body, service URLs underservices,match: null;docs/07§7 was corrected to match. - Closed — SCRUM-201. Compose uses
:8080foradmin-ui(nginx-unprivileged cannot bind 80). - Closed — SCRUM-194 (GWD-2).
gateway_devhas per-IP buckets: general 20 rps/burst 40,/admin-auth/5 rps/burst 10,/admin/and the catch-all unlimited./admin-auth/loginis no longer unlimited. - Open — out of scope (§6). The gateway grants
/api/admin/session/atviewerwhile Session's force-disband requireslive_ops. - Closed — SCRUM-195, SCRUM-201. Default body cap 1 MiB (512 MiB for packs); the parsed CORS config was deleted (same-origin by design).
- Closed — SCRUM-258…262. admin-auth now serves the onboarding pair, TOTP enroll/confirm,
/me'sis_root/mfa_enabled, the account routes andPOST /api/admin/users/{id}/mfa/reset; the admin UI calls them. - Closed — SCRUM-263, SCRUM-264. Config's schema PUT requires the version precondition (
If-Match/base_version,428 precondition_required,409 stale_schema) (SCRUM-263), and Dashboard's non-finite numbers serialise as JSONnull(SCRUM-264).