WorldForge Studio

01 — Authentication

Two actor kinds, one header: API keys for integrations, JWTs for first-party sessions.

Every request carries Authorization: Bearer <token>. The server tells the two token kinds apart by shape (ADR-0005, docs/adr/0005-auth-two-actor-kinds.md):

KindShapeWhoScopes
API key (agent)wk_live_<id>.<secret>integrations, agentsalways explicit, from the catalogue
JWT (user)3 dot-segments signed with JWT_SECREThumans via a session['*'] (wildcard)

Passing auth to the client

createClient takes baseUrl plus one auth option:

createClient({ baseUrl, apiKey: 'wk_live_…' });   // integrations (recommended)
createClient({ baseUrl, jwt: userJwt });          // first-party sessions
createClient({ baseUrl, getToken: async () => refreshedJwt() }); // short-lived tokens

getToken (() => string | Promise<string>) is called per request, so refresh flows need no client recreation. Passing none of the three throws WorldForgeConfigError at construction. Passing more than one does not throw: createClient silently picks the first present in the order apiKey, then jwt, then getToken (the ClientOptions doc comment says "mutually exclusive", but nothing enforces it) — pass exactly one.

ClientOptions also takes an optional fetch (an injectable fetch for tests, proxies or custom agents). The returned WorldForgeClient exposes baseUrl (trailing slashes stripped), the resource modules (02), raw, and exchangeSession.

The auth shapes are exported as types: AuthOption = ApiKeyAuth | JwtAuth | TokenProviderAuth. The /realtime functions take an auth: AuthOption (e.g. { apiKey }) instead of the flat options. /mcp's createMcpClient takes the flat apiKey / jwt / getToken options with the same precedence; with none it throws a plain Error (not WorldForgeConfigError).

Exchanging a session for a JWT

First-party browser consumers behind a same-origin proxy can exchange the forge_session cookie value:

import { exchangeSession } from '@blueforge-studio/worldforge-sdk';

const { jwt } = await exchangeSession(baseUrl, forgeSessionValue);
// or, on an existing client (reuses the client's injected `fetch`):
const { jwt: same } = await client.exchangeSession(forgeSessionValue);

exchangeSession(baseUrl, forgeSession, fetchImpl?) does POST /auth/session with { forge_session } and resolves to SessionExchangeResponse ({ jwt }); a non-2xx throws WorldForgeApiError.

External integrators should skip this and use an API key.

Scopes

API-key scopes are enforced centrally (auth/api-key-scopes.ts) by matching the request path against a rule table before the handler runs. The catalogue is derived from the rules; highlights:

Scope pairCovers
world:read / world:writeworld sub-resources (memberships, feed, stream, regions, presence, items, meshes, art, pois, spawns, entities-diff, dialogue, inventory, biomes, quests, cinematic, camera-paths, shots, proposals, proposal-policies, auto-accept), /atlas/{nodes,layers,usage,edges}, /notes
world:commitcommitting proposals (/proposals, /proposal-changesets); PATCH /worlds/:id with confirm: true
wiki:read / world:write/wiki
pack:read / pack:write/assets, /packs
print:read / print:write/print-orders, /print (/print/compile needs only read)
quest:read / quest:write, campaign:*, encounter:*, play:*gameplay content (/quests, /campaigns, /encounters, /play)
print-shop:write / print-shop:adminprint-shop owner / admin routes (checked by the routes themselves)
atlas:read, atlas:propose, wiki:propose, lore:read, lore:propose, <type>:commitchecked by routes and MCP tools themselves

The authoritative list is API_KEY_SCOPE_CATALOGUE in apps/api/src/auth/api-key-scopes.ts; POST /api-keys refuses anything outside it. The bare /worlds and /worlds/:id routes are not in the rule table — they call requireScopes themselves.

Rules with routeDecides split read/write by HTTP method (e.g. GET needs read, PATCH needs write). A denied key gets 403 { error: 'forbidden', missing: ['scope'] }.

What API keys cannot do

By design (routes/apiKeys.ts, routes/realm-tickets.ts, AI routes):

  • mint or revoke API keys — /api-keys requires a user session
  • open the realm WebSocket — /realm/tickets returns realm_requires_user_session; collaboration is a user-session surface
  • drive /api/ai — AI Studio is requireUserSession

Plan for these with a user JWT if your integration needs them, or drive collaboration through the REST + SSE surfaces instead.

Expiry and rotation

Keys expire — 90 days by default, 365 days maximum. Rotate by minting a second key, switching getToken/apiKey, then revoking the old one. An expired key fails like any other 401/403; check WorldForgeApiError .status and .reason.