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):
| Kind | Shape | Who | Scopes |
|---|---|---|---|
API key (agent) | wk_live_<id>.<secret> | integrations, agents | always explicit, from the catalogue |
JWT (user) | 3 dot-segments signed with JWT_SECRET | humans 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 pair | Covers |
|---|---|
world:read / world:write | world 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:commit | committing 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:admin | print-shop owner / admin routes (checked by the routes themselves) |
atlas:read, atlas:propose, wiki:propose, lore:read, lore:propose, <type>:commit | checked 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-keysrequires a user session - open the realm WebSocket —
/realm/ticketsreturnsrealm_requires_user_session; collaboration is a user-session surface - drive
/api/ai— AI Studio isrequireUserSession
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.