From 69c40b84939f4de46d457218655ebada831a9c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 15:19:09 +0000 Subject: [PATCH] docs(protocol): drop the five WebSocket message types `WebSocketMessageType` does not declare `WebSocketMessageType` is an exhaustive ten-member enum and `BaseWebSocketMessage` types every message's `type` against it, so a message whose type is outside the enum parses against no schema in the file. The realtime-protocol page taught five types that are not members: an in-band handshake (auth, auth_success, auth_error) and two acknowledgements (subscribed, unsubscribed). Measured on this tree. All ten declared members show at least one producer or consumer site under a message-type-context probe over packages/apps/examples; all five of the above return zero there, and zero in objectui. The contract agrees: IRealtimeService.handleUpgrade is optional and unimplemented, and WebSocketConfig.headers is the declared seam for a credential ("Custom headers for WebSocket handshake"). The five were documentation, not protocol. Prose only. The page now states the declared vocabulary, puts authentication on the upgrade request where the declared shapes put it, and shows the declared `ack` envelope for subscribe and unsubscribe acknowledgements. No schema, no enum and no behaviour changed; packages/spec/src/api/websocket.zod.ts is byte-unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../protocol/kernel/realtime-protocol.mdx | 133 ++++++++++-------- 1 file changed, 77 insertions(+), 56 deletions(-) diff --git a/content/docs/protocol/kernel/realtime-protocol.mdx b/content/docs/protocol/kernel/realtime-protocol.mdx index 298ce5154b..e07b8811c0 100644 --- a/content/docs/protocol/kernel/realtime-protocol.mdx +++ b/content/docs/protocol/kernel/realtime-protocol.mdx @@ -162,6 +162,30 @@ names none, which is why every host the open framework ships answers `false`. A WebSocket upgrade endpoint is part of the planned transport (`IRealtimeService.handleUpgrade()`) and is not yet served — realtime stays out of the open framework (maintainer ruling, 2026-09-04). If a host ever mounts one, its realtime service names the mounted path via `getChannelRoute()` and discovery advertises `routes.realtime`, `handlerReady: true` and `capabilities.websockets` in the same step (see #2462, #14646). +### Declared message vocabulary + +**`WebSocketMessageType` (`packages/spec/src/api/websocket.zod.ts`) is a closed enum, and it is the +only declaration this protocol has.** It names exactly ten message types: + +`subscribe` · `unsubscribe` · `event` · `ping` · `pong` · `ack` · `error` · `presence` · `cursor` · `edit` + +`BaseWebSocketMessage` types every message's `type` field as that enum, and each message schema pins +`type` to one of those literals, so a message whose `type` is outside the enum parses against **no** +schema in the file: `WebSocketMessageSchema` is a discriminated union over exactly those ten, and an +unknown discriminant matches no branch at all. + +Every message also carries the three `BaseWebSocketMessage` fields — `messageId` (UUID), `type`, and +`timestamp` (ISO 8601). + + + ⛔ **Five message types this page used to teach are not in that enum:** an in-band handshake + (`auth`, `auth_success`, `auth_error`) and two acknowledgements (`subscribed`, `unsubscribed`). + Nothing in the runtime produces or consumes any of the five, and a client built on them sends and + waits for messages the declared contract cannot carry. They were documentation, not protocol. The + sections below describe what the enum does carry: see **Authentication** for where a credential + goes instead, and `ack` for the acknowledgement the protocol actually declares. + + ### Establishing Connection **Client-side (JavaScript):** @@ -170,12 +194,8 @@ const ws = new WebSocket('wss://api.acme.com/ws'); ws.onopen = () => { console.log('Connected to ObjectStack'); - - // Authenticate - ws.send(JSON.stringify({ - type: 'auth', - token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' - })); + // No in-band auth message: the credential travels on the upgrade request + // (see Authentication). A socket that reaches `onopen` was already admitted. }; ws.onmessage = (event) => { @@ -194,38 +214,37 @@ ws.onclose = (event) => { ### Authentication -Send authentication message immediately after connection: +**The declared protocol has no authentication message.** A credential travels on the HTTP upgrade +request, before the socket exists — `WebSocketConfig.headers` ("Custom headers for WebSocket +handshake") is the declared seam for it, and `IRealtimeService.handleUpgrade(request)` receives that +`Request` with the headers attached. So there is nothing to send once the socket is open, and no +success or failure message to wait for: an upgrade that is refused never becomes a WebSocket, and the +client sees the HTTP rejection or an immediate close rather than a message. -**Request:** ```json { - "type": "auth", - "token": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + "url": "wss://api.acme.com/ws", + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } } ``` -**Success Response:** -```json -{ - "type": "auth_success", - "user_id": "user_123", - "session_id": "session_abc", - "expires_at": "2024-01-16T22:30:00Z" -} -``` +Browsers cannot set headers on `new WebSocket(...)`, so a browser client carries the credential the +way the host's upgrade route accepts it — a cookie already scoped to the origin, or a short-lived +ticket in the URL. Which of those a host accepts is that host's decision; `handleUpgrade` receives +the whole `Request` either way. -**Failure Response:** -```json -{ - "type": "auth_error", - "error": { - "code": "INVALID_TOKEN", - "message": "JWT token expired" - } -} -``` +Failures that arise **after** admission are ordinary `type: "error"` messages (`ErrorMessageSchema`, +flat `code` / `message` / `details`) — see **Token Expiration** for the one that matters most here. -**Connection closes on auth failure:** Server closes WebSocket if authentication fails within 10 seconds. + + ⛔ **Nothing serves any of this yet.** `handleUpgrade` is optional and unimplemented throughout the + open framework (#2462, #3197), and per `IRealtimeService` the delivery path carries **no + per-recipient authorization** at all — subscriptions carry no principal. Admission on the upgrade + request is where the declared shapes put a credential; it is not a claim that a served endpoint + checks one today. + ## Subscriptions @@ -253,12 +272,18 @@ Subscribe to changes on a specific object: - `filter`: Optional filter (same syntax as HTTP API filters) **Success Response:** + +The declared acknowledgement is an `ack` message (`AckMessageSchema`); there is no `subscribed` type. +`ackMessageId` echoes the `messageId` of the message being acknowledged, `success` says whether it was +accepted, and the optional `error` string carries the reason when it was not. + ```json { - "type": "subscribed", - "subscription_id": "sub_1", - "object": "task", - "events": ["created", "updated", "deleted"] + "messageId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "ack", + "timestamp": "2024-01-16T14:30:00Z", + "ackMessageId": "550e8400-e29b-41d4-a716-446655440000", + "success": true } ``` @@ -358,11 +383,16 @@ Stop receiving events for a subscription: } ``` -**Response:** +**Response:** the same `ack` envelope as a subscribe acknowledgement — there is no `unsubscribed` +type either. + ```json { - "type": "unsubscribed", - "subscription_id": "sub_1" + "messageId": "1f2b7d64-9a01-4c8e-9f3a-2b5c8d417e60", + "type": "ack", + "timestamp": "2024-01-16T14:35:00Z", + "ackMessageId": "c0ffee00-1111-4222-8333-444455556666", + "success": true } ``` @@ -565,9 +595,11 @@ Handle disconnections gracefully: ```javascript class ObjectStackClient { - constructor(url, token) { + // The credential is not held here: it travels on the upgrade request (see + // Authentication), so reconnecting re-presents it the same way the first + // connection did. + constructor(url) { this.url = url; - this.token = token; this.subscriptions = new Map(); this.reconnectDelay = 1000; // Start with 1 second this.maxReconnectDelay = 30000; // Max 30 seconds @@ -579,7 +611,6 @@ class ObjectStackClient { this.ws.onopen = () => { console.log('Connected'); this.reconnectDelay = 1000; // Reset backoff - this.authenticate(); this.resubscribe(); }; @@ -593,10 +624,6 @@ class ObjectStackClient { }; } - authenticate() { - this.send({ type: 'auth', token: this.token }); - } - resubscribe() { this.subscriptions.forEach((config, id) => { this.send({ ...config, subscription_id: id }); @@ -616,7 +643,7 @@ class ObjectStackClient { } // Usage -const client = new ObjectStackClient('wss://api.acme.com/ws', token); +const client = new ObjectStackClient('wss://api.acme.com/ws'); client.connect(); ``` @@ -1010,18 +1037,12 @@ ws.on('task.updated', (task) => { ## Security Considerations ### Authentication Required -All WebSocket connections must authenticate within 10 seconds: -```javascript -ws.onopen = () => { - ws.send(JSON.stringify({ - type: 'auth', - token: getToken() - })); -}; - -// Server closes connection if no auth within 10 seconds -``` +Every WebSocket connection is admitted on the **upgrade request**, before the socket exists — there +is no authentication message to send afterwards, because `WebSocketMessageType` declares none. A +connection that reaches `onopen` was already admitted; one that was refused never opened, and the +client sees the HTTP rejection or an immediate close. See **Authentication** for the declared seam +(`WebSocketConfig.headers` into `IRealtimeService.handleUpgrade`). ### Token Expiration Handle JWT expiration gracefully: