Hosted node HTTP API
The control-plane HTTP API manages rooms on a rooms node: create rooms,
mint one-time join tokens, look rooms up, drain them. One service key
per deployment, sent as Authorization: Bearer <service key>.
The endpoints at a glance:
| Method & path | Purpose |
|---|---|
POST /v1/rooms |
create a room (template + config) → room URL + one-time join token |
POST /v1/rooms/:id/tokens |
mint an additional one-time join token |
GET /v1/rooms/:id |
room lookup (status, peers, pinned config) |
GET /v1/rooms?limit=&cursor= |
cursor-paginated room list |
DELETE /v1/rooms/:id |
drain: peers get WebSocket close 1001, may not resume |
Control-plane HTTP API — v0 contract
This document is the contract for room management. Two implementations
will exist and must behave identically: the hosted control plane (Cloudflare
Workers + D1) and the local emulator (roombase dev). A third, in-process
implementation now ships in this repo: the self-contained rooms node
(packages/node) serves this API from its own listener over SQLite — see
Node implementation notes at the end for its v0 deviations.
Items are marked [DECIDED v0], [DEFERRED], or [TODO]. The data
plane (a node running the server_sdk room server behind a supervisor) is
out of scope for this document except where the contract touches it (join
tokens, room URLs, drain semantics).
Product shape: a hosted room service. Users do not write Dart. They create
rooms through this HTTP API and connect clients; the server runs
platform-provided authorities (see room_templates.md). Two template types
launch together: relay (client-authoritative state rows, quota-enforced —
core implemented in server_sdk as RelayAuthority) and movement
(server-simulated physics — spec only in v0).
Authentication
- [DECIDED v0] One service key per deployment, sent as
Authorization: Bearer <service key>. Plain constant-time comparison. - [DEFERRED] Per-game publishable/secret key pairs, quotas and billing meters per key. The v0 single key keeps the emulator and the Workers implementation trivially compatible; the header shape will not change.
All requests must be HTTPS (emulator: HTTP on localhost). Missing/wrong key →
401 unauthorized.
Room lifecycle
POST /v1/rooms — create a room
Request body (JSON):
{ "template": "relay", "config": { "...": "room config per room_templates.md, version 1" }, "region": "ap-southeast-1"}template— required,"relay"or"movement". Relay runs everywhere;movementruns on nodes wired with the physics backend ("movement": truein the node config, the default for the shipped image). A node that does not run a template returnsunsupported_template.config— required, validated against the template schema inroom_templates.mdbefore the room is provisioned. Unknown fields are rejected; configs are pinned verbatim at creation (append-only evolution — see that document).region— optional. [DEFERRED] Single-region v0; if present it is recorded and ignored.
Response 201 Created:
{ "roomId": "rm_9f3k2", "roomKey": "7YPL-xKq3-...", // unguessable; appears in the room URL "template": "relay", "url": "wss://rooms.example.com/play/7YPL-xKq3-...", "joinToken": "jt_C1AA...-only-shown-once", "joinTokenExpiresAt": "2026-12-01T12:15:00Z", "creatorToken": "ct_...", // relay rooms: present with the first join token "createdAt": "2026-12-01T12:00:00Z", "status": "live"}- [DECIDED v0] The room URL is signed by the control plane and embedded
in the response (
url). The signature authenticates the room-to-node binding; the node re-verifies it before the WebSocket upgrade. - [DECIDED v0] Exactly one one-time join token is minted at creation
(see Token semantics). Additional tokens are minted per join via
POST /v1/rooms/:id/tokens— rooms need more than one player. creatorToken— for relay rooms, the creating client presents this claim to become room leader. [DECIDED v0] opaque secret; [TODO join tokens] both tokens become signed JWTs carrying{"room": …, "creator": bool, "exp": …}andRelayAuthority’s constructor seam is fed by claim verification instead of a string comparison.
POST /v1/rooms/:id/tokens — mint a join token
[DECIDED v0] Body (optional): {"creator": false}. Response 201:
{"joinToken": "jt_…", "joinTokenExpiresAt": "…"}. Tokens are one-time and
expire (v0: 15 minutes). Only the service key holder (the game’s backend)
may mint tokens; minting is rate-limited [DEFERRED].
GET /v1/rooms/:id — room lookup
Response 200: roomId, roomKey, template, status
(live|draining|closed), createdAt, peers (currently connected count),
capacity, config (the pinned config), url (always present; the URL
itself is not one-time). Token state is not exposed (tokens are
bearer secrets; only mint-and-show-once).
GET /v1/rooms?limit=50&cursor=… — list rooms
[DECIDED v0] Cursor pagination (limit 1–200, default 50; response
carries nextCursor, absent on the last page). Ordered by createdAt
descending. Same shape as lookup, without config (list stays small).
DELETE /v1/rooms/:id — delete / drain a room
[DECIDED v0] Drains: the node closes the room; peers receive WebSocket
close code 1001 and may not resume. Response 204. Deleting a closed room
is idempotent (204). [DEFERRED] drain grace period / scheduled drain.
Token semantics
- One-time join. A join token authorizes exactly one successful WebSocket join on its room URL. The node validates it before the WebSocket upgrade (path + token + origin allowlist; see the supervisor auth-gate task) and marks it consumed on success.
- [DECIDED v0] A join token travels as a query parameter:
wss://host/play/<roomKey>?token=<joinToken>. - Reconnects do not need a new token. Within the room’s resume grace window (default 15 s) the client resumes with the room protocol’s resume token, which is unrelated to join tokens. Beyond the grace window a fresh join — and therefore a fresh join token — is required.
- [TODO reconnect rendezvous] Today
RoomClientretries a fixeduriforever (backoff capped ~4 s). With expiring one-time tokens this hammers a dead URL after the token lapses. The client SDK will grow a rendezvous callback (re-ask the game backend / control plane when the retry budget is exhausted). Until then, direct self-hostedws://use is unaffected and remains the documented escape hatch. creatorTokenfollows the same rules and additionally carries the leader claim (seeroom_templates.md→ relay → ownership).
Error shape
Every non-2xx response carries a JSON body:
{ "error": { "code": "invalid_config", "message": "human readable", "field": "config.playerKeys.pose.maxBytes" } }field— optional, dotted path of the offending input.- Codes → statuses [DECIDED v0]:
| Code | HTTP | When |
|---|---|---|
unauthorized |
401 | missing/invalid service key |
not_found |
404 | unknown roomId |
unsupported_template |
422 | template exists but no node runs it yet |
invalid_config |
422 | config fails room_templates.md validation |
invalid_request |
400 | malformed body/params |
quota_exceeded |
429 | room/token creation over quota |
conflict |
409 | e.g. DELETE while a conflicting operation holds |
internal |
500 | anything else; message must not leak internals |
[DEFERRED] request-level rate limiting, idempotency keys on POST, audit log, webhooks/events for room lifecycle.
Consistency with the rest of the system
- Config validation rules live in
room_templates.md(single source of truth); this API only references them. - The data-plane node’s pre-upgrade auth gate (origin allowlist + join-token
validation + per-IP throttling) is specified in the platform plan’s
supervisor task; this document fixes what the node must verify: valid
signature on
url, unconsumed matchingtoken, room not draining. - The emulator implements this contract in-process with an in-memory store; “identical client code path offline vs hosted” is its acceptance test.
Node implementation notes (v0)
The rooms node (packages/node, bin/node.dart) implements this contract
in-process on its single listener, over SQLite (rooms, token_uses,
api_keys tables; see server_sdk → NodeStore). Deviations from the
letter of the contract, all v0-scoped:
- Room URL signature. The contract has the control plane sign the room
URL and the node re-verify that signature. In the single-node v0 the
join token is the credential: the URL is built from the configured
publicBaseUrland carries no separate signature; pre-upgrade token verification (HMAC under the node secret, expiry, room binding, one-time consumption) authenticates the join. A signed-URL scheme rides the same HMAC machinery when multi-node routing arrives. - Draining.
DELETEcloses the room immediately (peers get1001); thedrainingstatus is never emitted — a room isliveorclosed. - API keys. Static keys from the node config, seeded into the
api_keystable at boot (constant-time compare). Per-game keys extend the same table. - One port. Control plane and data plane share the listener;
/v1/*vs/play/<roomKey>routing. TLS terminates at the reverse proxy (Caddy in the shipped compose file). - Restart semantics. Room state is memory-only; rooms pinned in SQLite are lazily recreated from their config on the next join after a node restart (fresh state, same room key). Join-token consumption persists in SQLite, so minted tokens survive restarts.
- Token TTLs. Minted join tokens default to 15 minutes
(
joinTokenTtlSeconds, 60–86400 at the node).POST …/tokenswith{"creator": true}returns the room’s existing creator token alongside the minted join token.
Generated from docs/control_plane_api.md (embedded above via markdown
import), including its “Node implementation notes” for the self-hosted
node’s v0 deviations.