Skip to content

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; movement runs on nodes wired with the physics backend ("movement": true in the node config, the default for the shipped image). A node that does not run a template returns unsupported_template.
  • config — required, validated against the template schema in room_templates.md before 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": …} and RelayAuthority’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 RoomClient retries a fixed uri forever (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-hosted ws:// use is unaffected and remains the documented escape hatch.
  • creatorToken follows the same rules and additionally carries the leader claim (see room_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 matching token, 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 publicBaseUrl and 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. DELETE closes the room immediately (peers get 1001); the draining status is never emitted — a room is live or closed.
  • API keys. Static keys from the node config, seeded into the api_keys table 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 …/tokens with {"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.