WorldForge Studio

03 — Collaboration & realtime

Memberships, the activity feed, the SSE live view, presence, and realm tickets for the Yjs WebSocket.

Memberships

MethodPathNotes
GET/worlds/:id/membershipsList members + roles
POST/worlds/:id/membershipsAdd a member ({ userId, role })
DELETE/worlds/:id/memberships/:userIdRemove

Owner-managed. Roles feed resolveWorldRole on every other route. Scopes: world:read / world:write.

Feed & presence

MethodPathNotes
GET/worlds/:id/feedActivity feed entries (audience-filtered)
POST/worlds/:id/presenceAnnounce presence (viewer)
DELETE/worlds/:id/presenceLeave

There is no presence read endpoint — presence arrives over the SSE stream below (presence events).

SSE live view

GET /worlds/:id/stream        (text/event-stream)

Membership-gated push stream: world mutations, presence changes, proposal events and keepalives. On the wire the SSE event: field is the event kind (presence, proposal, keepalive, …) and data: is the JSON payload. The server sends no id: field, so there is no Last-Event-ID resume: the stream is edge-triggered — after a gap, re-read GET /worlds/:id.

Access: the world's owner or any member. Missing world → 404 { error: 'not_found' }; a non-member → 403 { error: 'forbidden', reason: 'no_membership' }; no user behind the caller → 403 … 'unresolved_caller'. Responses carry Cache-Control: no-cache, no-transform and X-Accel-Buffering: no. API keys fall under the world:read scope rule (stream is a world sub-resource).

EventPayloadWhen
presence{ viewers: string[] } (user ids currently connected)On every connect and disconnect, to all viewers
keepalive{ tick: number }Every 15 s per connection
proposal{ id, changesetId, status }Proposal opened/committed/rejected/expired — owner and GMs only

Other event kinds are published by the feed/visibility writers via publishToWorld; their payloads are not enumerated here. The registry is in-process, so with several API processes a viewer only sees events written by the process it is connected to.

The SDK wraps this as an async iterable (/realtime subpath) using fetch + its own SSE parser — the browser EventSource cannot set an Authorization header.

Realm tickets (Yjs WebSocket)

MethodPathNotes
POST/realm/ticketsMint a single-use ticket for the WS upgrade

User session only — an API key gets 403 { error: 'forbidden', reason: 'realm_requires_user_session' }. Tickets are single-use, expire in 60 s, and are burned on upgrade. The WebSocket itself mounts at:

ws(s)://<api-origin>/realm/:worldId?ticket=<ticket>

JWT-in-query (?token=) exists only behind REALM_ALLOW_QUERY_JWT=true as a legacy fallback. Collaborative editing is a first-party surface; integrations that only need the document should poll GET /worlds/:id instead.

Branch access note

Branch routes (/worlds/:id/branches, accept, …) live on the worlds page; they require branch-level access on both sides of the fork (authz audit #242).