03 — Collaboration & realtime
Memberships, the activity feed, the SSE live view, presence, and realm tickets for the Yjs WebSocket.
Memberships
| Method | Path | Notes |
|---|---|---|
GET | /worlds/:id/memberships | List members + roles |
POST | /worlds/:id/memberships | Add a member ({ userId, role }) |
DELETE | /worlds/:id/memberships/:userId | Remove |
Owner-managed. Roles feed resolveWorldRole on every other route.
Scopes: world:read / world:write.
Feed & presence
| Method | Path | Notes |
|---|---|---|
GET | /worlds/:id/feed | Activity feed entries (audience-filtered) |
POST | /worlds/:id/presence | Announce presence (viewer) |
DELETE | /worlds/:id/presence | Leave |
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).
| Event | Payload | When |
|---|---|---|
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)
| Method | Path | Notes |
|---|---|---|
POST | /realm/tickets | Mint 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).