Authorization
Decide who may open a document with authorize, bind client ids to users, and grant read-only access.
The room enforces identity, but it does not know your users. You tell it who is connecting in authorize, a function routeDocumentSocket calls before it opens a socket. This page covers what authorize returns, how identity reaches the room, and how the room ties every edit to a user.
authorize
type AuthorizeDocumentSocket = (
request: Request,
documentId: string
) =>
| DocumentIdentity
| ExpiredCredential
| null
| Promise<DocumentIdentity | ExpiredCredential | null>;
type DocumentIdentity = {
userId: string;
replica?: number | null;
readOnly?: boolean;
};
type ExpiredCredential = { expired: true };
authorize receives the client’s original upgrade request (with its query string and cookies) and the document id you passed to routeDocumentSocket. Return null to refuse. The room is never reached: routeDocumentSocket accepts the upgrade and closes it at once with code 4403 and reason document access denied. A browser reports an HTTP 403 at the upgrade as a bare 1006, the same as a network failure; the 4403 close lets the provider tell a denial from being offline. The provider treats it as final: it emits refused once and stops dialing (see refusals).
Return { expired: true } when the credential was valid but has expired. The dial is accepted and closed with 4401 and reason expired, and the room is never reached. The provider does not stop: it emits expired, then redials after its backoff, reading params again. Put a fresh token in params when expired fires and the next dial gets in.
| Field | Type | Description |
|---|---|---|
userId |
string |
The verified user, 1 to 256 characters. The room binds every client id this socket writes under to this user. |
replica |
number | null |
The client’s CRDT client id (document.clientID), usually read with requestedReplica(request). When omitted, the socket’s first presence message binds it. |
readOnly |
boolean |
Refuse every write on this socket. Reads and presence still work. |
routeDocumentSocket answers without calling the room when the request is not a WebSocket upgrade (426), when the document id is empty or longer than 256 characters (closed 4400), when authorize returns { expired: true } (closed 4401), or when it returns null or an invalid identity (closed 4403).
| Close | Sent when | The provider |
|---|---|---|
4400 invalid document id |
The document id is empty or longer than 256 characters; authorize is not called. |
Final: emits refused and stops dialing. |
4401 expired |
authorize returned { expired: true }. |
Emits expired and redials after its backoff, with params read again. |
4403 document access denied |
authorize returned null or an invalid identity. |
Final: emits refused and stops dialing. |
4409 replica bound to another user |
The room: the dial’s replica belongs to another user (see replicas). | Final: emits refused and stops dialing. |
Your Worker may turn a dial away itself, before routeDocumentSocket: a document that does not exist, an origin you do not serve. Answer the upgrade with closedSocket(code, reason) from edytor/cloudflare rather than an HTTP error, which the browser reports as 1006 and the provider redials forever. It accepts the upgrade and closes it at once with code: use a 4xxx code other than 4401 for a final refusal (4404 for a document that does not exist, say), or 1011 for a fault the provider should retry.
if (!(await documentExists(documentId))) return closedSocket(4404, 'unknown document');
Example: a signed token
Browsers cannot set headers on a WebSocket, so send a token as a query parameter (or rely on a cookie). This example verifies a JWT with jose and looks up the user’s access to the document in your own data:
import { jwtVerify } from 'jose';
import { DocumentRoom, requestedReplica, routeDocumentSocket } from 'edytor/cloudflare';
export { DocumentRoom };
type Env = {
ROOMS: DurableObjectNamespace<DocumentRoom>;
JWT_SECRET: string;
};
// Your permission model: 'edit', 'view', or null.
declare function accessTo(env: Env, userId: string, documentId: string): Promise<'edit' | 'view' | null>;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const match = /^\/rooms\/([^/]+)$/.exec(new URL(request.url).pathname);
if (!match) return new Response('not found', { status: 404 });
return routeDocumentSocket(request, env.ROOMS, decodeURIComponent(match[1]), async (request, documentId) => {
const token = new URL(request.url).searchParams.get('token');
if (!token) return null;
try {
const { payload } = await jwtVerify(token, new TextEncoder().encode(env.JWT_SECRET));
if (typeof payload.sub !== 'string') return null;
const access = await accessTo(env, payload.sub, documentId);
if (!access) return null;
return {
userId: payload.sub,
replica: requestedReplica(request),
readOnly: access === 'view'
};
} catch (error) {
// An expired token is retried (4401) once `params` holds a fresh one; a forged one is refused (4403).
return (error as { code?: string }).code === 'ERR_JWT_EXPIRED' ? { expired: true } : null;
}
});
}
} satisfies ExportedHandler<Env>;
The client sends the token; the provider adds its replica id (?replica=<clientID>) at every dial:
<Edytor server="wss://rooms.example.com/rooms" room={documentId} params={{ token }} actor={{ id: userId }} />
params is read at every reconnect; pass a new token to refresh an expiring one (see WebSocket options). An expired token closes the dial with 4401: the provider emits expired (the view’s onSyncExpired) and dials again with the params it then reads, so refresh token there or before it expires. A denied dial (4403) stops the provider: once the permission is fixed, reload the page, or call connect() on a WebsocketProvider you created, to dial again. With a session cookie instead, read request.headers.get('Cookie') in authorize and look the session up; the cookie is sent with the upgrade when the Worker is on the same site.
How identity reaches the room
After authorize succeeds, routeDocumentSocket builds a new request for the room (rooms.getByName(documentId), a DocumentRoom or any object with attachDocument) that carries only the verified identity:
| Header | Value |
|---|---|
X-Edytor-User |
userId |
X-Edytor-Replica |
replica, when known |
X-Edytor-Access |
read or write |
Every header the client sent is dropped, cookies and tokens included, and so are forged X-Edytor-* headers. The room trusts these three headers without checking anything else, and refuses a request without them (401).
Replicas
Every browser tab has a CRDT client id (document.clientID, the replica), and every piece of content records the client id that wrote it. The room makes sure nobody writes under a client id that is not theirs:
- Each client id is registered to one user. The registration is stored in the room beside the document, and
onSavereceives it asreplicas: store it withupdateand return both fromonLoad, or a restored room cannot tell who owns which id. - The socket’s own replica (from
authorize, or bound by its first presence entry) is registered when the socket opens, for read-only sockets too, so nobody can take a viewer’s id before it is granted edit. A dial whose replica another user owns is accepted, then closed with4409(replica bound to another user); the provider treats it as final. - A user may write under every id registered to them. A reloaded page gets a new client id, and the edits its earlier id made offline are still delivered on reconnect.
- Content under a client id another user owns, or under an unregistered id that already has content, is stripped from the update (logged as a
replicarefusal, with the id). So are the update’s deletes of that id’s items the room does not hold, and of the entries the stripped content replaces (a changed attribute keeps its stored value, instead of being emptied). The rest of the update is applied, including its deletes of that id’s stored items, since anyone who may write may delete: the socket stays connected and its own edits are stored. - Content under an id the room holds nothing of, and no user owns, is relayed content: another replica’s edits, which this client received and passes on (edits restored from a local copy, or edits a room restored from an older snapshot lost). It is stored, logged as
relayed, and the id stays unowned: only a socket whose own replica is that id claims it (logged asorphan). Delivering an id never makes it yours.
requestedReplica(request, param = 'replica') reads the replica from the query string and returns null when it is missing or not a valid client id. The shipped provider sends it at every dial. Without a replica from authorize, the socket’s first presence entry binds it (and registers it, as a dial would); until then, the room cannot tell the socket’s own id from one it relays, so an unregistered id without content that the socket writes under is registered to its user.
Registrations of ids that hold no content and belong to no open socket are dropped when the room compacts. After a restore from a bare update (no replicas), each id that holds content is unowned until a signed-in writer dials with it.
Restoring from an older snapshot
If the room’s storage is lost and onLoad returns a snapshot older than the last edits, clients still hold edits the room lacks, including other users’ edits they received. Their handshake delivers them:
- Edits under an id another user owns, or under an id the room already holds part of, are stripped. The author’s own client delivers them when it reconnects. The relaying client’s own edits are stored meanwhile, its deletions of the author’s stored text included; an edit of its own that builds on stripped content waits in the room’s memory until the author’s edit arrives.
- Edits under an id the room holds nothing of are stored as relayed content, and the id stays claimable by its author’s dial.
Every author keeps their client ids, and nobody is disconnected for relaying. The relaying client’s saved turns true once the room has stored its user’s edits, after a reload too: it tracks what that user’s client ids wrote (the document binds each id to its actor), not the stripped edits of others it received earlier.
Because client ids belong to users, a browser profile’s local copy and cross-tab channel should belong to one user. See one user per browser profile.
Read-only sockets
When authorize returns readOnly: true:
- the client catches up with the document and receives live edits;
- it shares presence, so others see its caret;
- the room never asks it for its state, and every edit it sends is dropped with a
permission-deniedreply (read-only). The socket stays open, and the provider emits'permission-denied'.
Render read-only users with a read-only view. A readonly view does not attach its sync prop, so attach the sync to the document yourself:
const document = createDocument({ actor: { id: userId } });
document.attachSync(createWebsocketSync({ server: serverUrl, room: documentId, params }));
// <Edytor {document} readonly />
What edytor does not do
Edytor ships no user accounts, sessions, sharing model or permission rules. authorize is where your application decides who may open which document and whether they may edit it; the room enforces that decision and nothing more.