Skip to content

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.1
Host: <gateway>
Content-Type: application/json

{"device_id":"q3JqN1mG4c8u9xYwT0bVn5dKs2LpR7eHf6AzWiUoQyE"}
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
X-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.1
Host: <gateway>
If-None-Match: "9f2c…"
HTTP/1.1 304 Not Modified
ETag: "9f2c…"
Cache-Control: no-cache
X-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.1
Host: <gateway>
Range: bytes=1048576-
HTTP/1.1 206 Partial Content
Content-Type: application/octet-stream
Content-Range: bytes 1048576-48213903/48213904
Cache-Control: public, max-age=31536000, immutable
ETag: "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.1
Host: <gateway>
Authorization: Bearer <staff jwt>
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "9f2c…"
Cache-Control: no-cache
X-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.1
Host: <gateway>
Authorization: Bearer <staff jwt>
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "9f2c…"
Cache-Control: no-cache
X-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.1
Host: <gateway>
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Host: <gateway>
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Host: <gateway>
Authorization: Bearer <staff jwt>
HTTP/1.1 404 Not Found
Content-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.1
Host: <gateway>
HTTP/1.1 404 Not Found
Content-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.1
Host: <gateway>
HTTP/1.1 404 Not Found
Content-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.1
Host: <gateway>
HTTP/1.1 404 Not Found
Content-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.1
Host: <gateway>
Authorization: Bearer <expired player jwt>
HTTP/1.1 401 Unauthorized
Content-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.1
Host: <gateway>
Authorization: Bearer <staff jwt>
HTTP/1.1 401 Unauthorized
Content-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.1
Host: <gateway>
(no Authorization)
HTTP/1.1 401 Unauthorized
Content-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 Requests
Content-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 Requests
Content-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 Error
Content-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.1
Host: <gateway_dev>
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache
Content-Security-Policy: default-src 'self'; …
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-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.1
Host: <gateway_dev>
Content-Type: application/json

{"email":"ada@otomo.internal","password":"hunter2"}
HTTP/1.1 200 OK
Content-Type: application/json
Set-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.1
Host: <gateway_dev>
Cookie: __Host-otomo_refresh=…
HTTP/1.1 200 OK
Set-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.1
Host: <gateway_dev>
Authorization: Bearer <viewer staff jwt>
HTTP/1.1 200 OK
Content-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.1
Host: <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 Created
Content-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.1
Host: <gateway_dev>
Authorization: Bearer <live_ops staff jwt>
Content-Type: application/json

{ …same body… }
HTTP/1.1 403 Forbidden
Content-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.1
Host: <gateway_dev>
Authorization: Bearer <staff jwt>
Accept: text/event-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store

data: {"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.1
Host: <gateway_dev>
Authorization: Bearer <staff jwt>
HTTP/1.1 200 OK
Content-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.1
Host: <gateway_dev>
Authorization: Bearer <staff jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Host: <gateway_dev>
HTTP/1.1 404 Not Found
Content-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.1
Host: <gateway_dev>
HTTP/1.1 404 Not Found
Content-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.1
Host: <gateway_dev>
(no Authorization)
HTTP/1.1 401 Unauthorized
Content-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.1
Host: <gateway_dev>
Authorization: Bearer <live_ops staff jwt>
HTTP/1.1 403 Forbidden
Content-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.1
Host: <gateway_dev>
Authorization: Bearer <live_ops staff jwt>
Content-Type: application/octet-stream
Content-Length: 48213904
HTTP/1.1 201 Created
Content-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 Requests
Content-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 Large
Content-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.1
Host: auth:8080
HTTP/1.1 200 OK
Content-Type: application/json
Cache-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.1
Host: auth:8080
Content-Type: application/json

{"device_id":"q3JqN1mG4c8u9xYwT0bVn5dKs2LpR7eHf6AzWiUoQyE"}
HTTP/1.1 200 OK
Content-Type: application/json
Cache-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 Request
Content-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.1
Host: auth:8080
Content-Type: application/json

{"refresh_token":"n3JqN1mG4c8u9xYwT0bVn5dKs2LpR7eHf6AzWiUoQyE"}
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

(row 2's body, with a new refresh_token)

or

HTTP/1.1 401 Unauthorized
Content-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.1
Host: auth:8080
Content-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.1
Host: auth:8080 (also /auth/refresh, /auth/logout)
HTTP/1.1 405 Method Not Allowed
Allow: POST
Content-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.1
Host: auth:8080
HTTP/1.1 404 Not Found
Content-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.1
Host: auth:9090
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

ok
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.1
Host: auth:9090
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

ok

or

HTTP/1.1 503 Service Unavailable
Content-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.1
Host: auth:9090
HTTP/1.1 200 OK
Content-Type: text/plain; version=0.0.4; charset=utf-8

# HELP auth_jwks_keys_active Active signing keys
# TYPE auth_jwks_keys_active gauge
auth_jwks_keys_active 1
auth_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.1
Host: auth:9090
HTTP/1.1 200 OK
Content-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.1
Host: auth:8080
HTTP/1.1 404 Not Found
Content-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/namespaces
Authorization: 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 OK
ETag: "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 .../schema
If-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/packs
Content-Type: application/octet-stream
GDPC…<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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
Content-Type: application/json

{"display_name":"Tanuki"}
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
Content-Type: application/json

{"status":"online"}
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
Content-Type: application/json

{"display_name":"Tanuki","discriminator":"4417"}
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
Content-Type: application/json

{"player_id":"<uuid>"}
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <staff jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <staff jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <staff jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <staff jwt>
HTTP/1.1 501 Not Implemented
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 401 Unauthorized
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, PATCH
Content-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 Unauthorized
Content-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.1
Authorization: Bearer <player jwt>
HTTP/1.1 404 Not Found
Content-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.1
Host: session:9090
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

ok

or

HTTP/1.1 503 Service Unavailable
Content-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/manifest
If-None-Match: "9f2c…"
304 Not Modified
ETag: "9f2c…"
Cache-Control: no-cache
X-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 OK
Content-Type: application/json
ETag: "9f2c…"
Cache-Control: no-cache
X-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 Content
Content-Type: application/octet-stream
Content-Range: bytes 1048576-48213903/48213904
Cache-Control: public, max-age=31536000, immutable
ETag: "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/overview
Authorization: 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 OK
Content-Type: text/event-stream
Cache-Control: no-store

data: {"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 OK
Content-Type: application/json
Cache-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 OK
Set-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/refresh
Cookie: __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/logout
Cookie: __Host-otomo_refresh=…
204 No Content
Set-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/me
Authorization: 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/users
Authorization: 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.1
Host: <gateway_dev>
HTTP/1.1 200 OK
Cache-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.1
Content-Type: application/json
credentials: 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.1
Content-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.1
Content-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.1
Content-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.1
Content-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.1
Content-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.1
credentials: 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.1
credentials: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: Bearer <access token>
{}
{"secret":"…","otpauth_url":"…"} services/adminui/src/api/account.ts:129
17 POST /admin-auth/mfa/confirm HTTP/1.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: Bearer <access token>
204 No Content services/adminui/src/api/users.ts:132
22 PATCH /api/admin/users/{id} HTTP/1.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: Bearer <access token>
Content-Type: application/octet-stream

GDPC…<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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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.1
Authorization: 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:

{"error":{"code":"<machine-readable token>","message":"<human sentence>","request_id":"<id>"}}
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

  1. Two gateway binaries, not one with a flag — reachability must not be a runtime env var on an internet-facing container.
  2. Every backend re-verifies — the private network is not an authorization decision.
  3. Two identity domains, EdDSA both — one validator, one Claims struct, no algorithm-substitution hole.
  4. 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.
  5. Paths unstripped — one path space; the SPA lives at /admin/ to match.
  6. Stream: true per route — clearing the write deadline is a reduction in protection and must not be global.
  7. Long-poll, not WebSockets — all-REST, and a missed event delays the UI but cannot corrupt state.
  8. Immutability and content addressing — an update check is one request that usually answers 304.
  9. Live publish needs admin, expressed in the route table — no if channel == "live" to forget.
  10. 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.

  1. Closed — SCRUM-200 (GWD-1/D8). dev.go gates /api/admin/config/ at RoleViewer; Config's own table keeps writes at live_ops/admin.
  2. Closed — SCRUM-240 (OPS-1). deploy/compose.yaml now defines postgres, valkey, the observability stack, auth, admin-auth, config, patch, dashboard, admin-ui, gateway and gateway_dev. session joined compose in SCRUM-268 (session-migrate, then session); allocator and matchmaker are still deliberately absent.
  3. Closed in code (services exist and are in compose) — SCRUM-223…227, SCRUM-232…239. patch and dashboard are in services/ with real handlers/route tables and are wired into compose. Dashboard's six routes are no longer 501 placeholders.
  4. Closed — SCRUM-200. The staff issuer is pinned to https://admin-auth.otomo.internal in code and compose; the php-admin spelling is gone.
  5. Closed — SCRUM-195 (GWD-3). Per-route MaxBody with Upload clears 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.
  6. Open — out of scope (§6). docs/04-session-minimal.md §5 still omits GET /api/admin/session/audit; the session admin surface is not part of this change.
  7. Closed — SCRUM-209 (AA-10). GET /admin-auth/audit is registered in server.go and implemented in internal/api/audit.go; Dashboard's fan-out consumes it.
  8. Closed — SCRUM-84. The hand-off payload follows docs/06 §12: schema_version at the top level of the login and refresh body, service URLs under services, match: null; docs/07 §7 was corrected to match.
  9. Closed — SCRUM-201. Compose uses :8080 for admin-ui (nginx-unprivileged cannot bind 80).
  10. Closed — SCRUM-194 (GWD-2). gateway_dev has per-IP buckets: general 20 rps/burst 40, /admin-auth/ 5 rps/burst 10, /admin/ and the catch-all unlimited. /admin-auth/login is no longer unlimited.
  11. Open — out of scope (§6). The gateway grants /api/admin/session/ at viewer while Session's force-disband requires live_ops.
  12. Closed — SCRUM-195, SCRUM-201. Default body cap 1 MiB (512 MiB for packs); the parsed CORS config was deleted (same-origin by design).
  13. Closed — SCRUM-258…262. admin-auth now serves the onboarding pair, TOTP enroll/confirm, /me's is_root/mfa_enabled, the account routes and POST /api/admin/users/{id}/mfa/reset; the admin UI calls them.
  14. 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 JSON null (SCRUM-264).