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: