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.

ClientYour backendRelaylog in (your own auth)credentialupgrade ?doc=1234 + credentialcheck access → role403, or 101 + sessionbinary frames
Access is checked once, at the upgrade. Frames then flow without further checks.

Roles

RoleConnect and downloadUpload changes
none403 before the upgrade—
readeryesrejected with FORBIDDEN
editoryesyes, 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.

refresh.ts
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.

server.ts
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.

store.ts
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 FORBIDDEN and keeps the socket open. Check getPulseMeta(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.