Self-host: authentication
Pulsify stores no users and no access lists. Your server decides who may open which document, once, before the WebSocket upgrade. After that, a session is pinned to one document with one role.
Roles
| Role | Connect and download | Upload changes |
|---|---|---|
| none | 403 before the upgrade | — |
reader | yes | rejected with FORBIDDEN |
editor | yes | yes, including creating the document |
Refreshing tokens
The token travels in the query string: keep it short-lived. Pass a socket factory so each new session reads the current token.
import { createWebSocketConnector } from "pulsify-core";
let token = await fetchToken();
setInterval(async () => (token = await fetchToken()), 10 * 60_000);
const relay = createWebSocketConnector(
"wss://relay.example.com/sync?doc={docId}",
// Called for every new session: reconnections and sub-pulses get a fresh token.
(url, protocols) => new WebSocket(`${url}&token=${token}`, protocols),
{ protocolVersion: 2 },
);
Self-host
Authenticate the upgrade request however you like (cookie, header, token), then open the relay peer with the document and the role.
import { PulseRelay, type RelayPeer } from "pulsify-core";
const relay = new PulseRelay();
type Session = { docId: string; readOnly: boolean; peer?: RelayPeer };
Bun.serve<Session>({
port: 3000,
async fetch(request, server) {
const docId = new URL(request.url).searchParams.get("doc");
const user = await userFromCookie(request); // your auth
const role = user && docId ? await roleOf(user, docId) : undefined; // your rules
if (!docId || !role) return new Response("Forbidden", { status: 403 });
const data = { docId, readOnly: role !== "editor" };
return server.upgrade(request, { data }) ? undefined : new Response(null, { status: 400 });
},
websocket: {
open(socket) {
const { docId, readOnly } = socket.data;
socket.data.peer = relay.open((bytes) => socket.sendBinary(bytes), { docId, readOnly });
},
message(socket, message) {
if (typeof message === "string") return socket.close(1003);
socket.data.peer!.receive(new Uint8Array(message));
},
close(socket) { socket.data.peer?.close(); },
},
});
Serving from a database? relayStoreMessage takes the same session options.
import { relayStoreMessage } from "pulsify-core/protocol";
const { responses } = await relayStoreMessage(store, bytes, { docId, readOnly });
Errors and revocation
- Refused upgrade: browsers hide the HTTP status, so the client only sees
CONNECTION_FAILED. Fetch the token first to tell auth errors from network errors. - Reader upload: the relay answers
FORBIDDENand keeps the socket open. CheckgetPulseMeta(pulse).status. - Revocation: roles are evaluated when the socket opens. An expired token or removed grant applies on the next connection; close the socket server-side to cut access now.