Authentication

Authentication is built in and split in two. A token says who the user is; your backend signs it after your own login. A grant table in each root says what that user may do. The cloud checks the token once per socket and the grants on every frame.

ClientYour backendGatewayRootGET /api/pulsify-tokensignCloudToken({ subject })tokenupgrade + token subprotocolverify signature, expirysocket for subjectconnect alice/notes/n1grants → rolerole + sync, or FORBIDDEN
Identity comes from your backend; rights come from the root.

Secrets

environment
PULSIFY_TOKEN_SECRET  shared by your backend and the cloud; signs user tokens (≥ 32 chars)
PULSIFY_API_KEY       your backend only; manages grants and reads usage (≥ 32 chars)

Sign tokens

Tokens are short-lived HS256 JWTs with the pulsify-cloud audience. They carry no rights, so they never go stale when you share a document.

backend.ts
import { signCloudToken } from "pulsify-cloud";

// GET /api/pulsify-token, behind your own login (session cookie, OAuth, …)
export async function pulsifyToken(user: User) {
  const token = await signCloudToken(
    { subject: user.id },                 // who, not what: rights live in the roots
    process.env.PULSIFY_TOKEN_SECRET!,
    { expiresIn: "15m" },
  );
  return new Response(token);
}

The connector calls its token function for each new socket. An expired token only matters at the next reconnection: open sockets are not cut when it expires.

Roles

RoleLoad and receive updatesUpload changes, create documentsManage grants of the prefix
noneFORBIDDEN——
readeryesFORBIDDENno
editoryesyesno
owneryesyesyes

A grant covers its prefix and everything below it. Grants only add access, and the strongest one wins. The subject * matches every authenticated user.

how grants resolve
grants of root "alice":
  alice  alice           owner
  bob    alice/notes/n1  editor
  *      alice/public    reader

                     alice   bob     carol
alice                owner   —       —
alice/notes/n1       owner   editor  —
alice/notes/n1/img   owner   editor  —      inherited
alice/notes/n2       owner   —       —
alice/public/cv      owner   reader  reader "*" is every authenticated user
alice2               —       —       —      another root: prefixes stop at "/"

Grant rights from your backend

Nothing is granted automatically, not even to a user on the root named after them. Grant it when your app creates the user, the team or the project.

backend.ts
import { createCloudClient } from "pulsify-cloud";

const cloud = createCloudClient({
  url: process.env.PULSIFY_URL!,
  credential: process.env.PULSIFY_API_KEY!,   // never ship this key to a browser
});

// A new user owns the root named after them
await cloud.grant({ subject: user.id, prefix: user.id, role: "owner" });

// A team root, shared by its members
await cloud.grant({ subject: "u1", prefix: "acme", role: "owner" });
await cloud.grant({ subject: "u2", prefix: "acme", role: "editor" });
await cloud.grant({ subject: "*", prefix: "acme/handbook", role: "reader" });

await cloud.grants("acme");                          // every grant of the root
await cloud.grants("acme", { subject: "u2" });       // filtered
await cloud.revoke({ subject: "u2", prefix: "acme" });
await cloud.usage("acme"); // { root, documents, rows, bytes, connections }

Let owners share

An owner can grant and revoke roles inside the prefixes they own, with their own token. That is enough for a share dialog without a backend route.

share.ts
import { createCloudClient } from "pulsify-cloud";

// In the browser, with the user's own token: owners manage their subtree.
const access = createCloudClient({
  url: PULSIFY_URL,
  credential: () => fetch("/api/pulsify-token").then((response) => response.text()),
});
await access.grant({ subject: "bob", prefix: note.id, role: "editor" });
await access.grants("alice"); // grants under the prefixes Alice owns, and her own

Every client is a thin layer over the same HTTP API:

terminal
curl -X PUT https://pulsify.example.com/v1/roots/alice/grants \
  -H "Authorization: Bearer $PULSIFY_API_KEY" \
  -d '{"subject":"bob","prefix":"alice/notes/n1","role":"editor"}'
# {"grant":{"subject":"bob","prefix":"alice/notes/n1","role":"editor"}}

curl -X DELETE "https://pulsify.example.com/v1/roots/alice/grants?subject=bob&prefix=alice%2Fnotes%2Fn1" \
  -H "Authorization: Bearer $PULSIFY_API_KEY"
# {"removed":true}

# Errors: {"error":{"code":"FORBIDDEN","message":"…"}} with the matching HTTP status

Live changes

Grants are stored in the root that serves the sockets, so a change applies to open sessions at once:

  • Upgrade (reader → editor): the client receives the new role and its next uploads are accepted.
  • Downgrade (editor → reader): later uploads are refused with FORBIDDEN.
  • Revocation: the document is unsubscribed and the pulse fails with FORBIDDEN. Other documents on the same socket are untouched.

Show the role in your UI, for example to make an editor read-only:

roles.ts
const cloud = createCloudConnector({ url, token });

cloud.roleOf("alice/notes/n1");   // "reader" | "editor" | "owner" | null | undefined
const stop = cloud.onRole((docId, role) => {
  // Sent when a document is opened, then on every grant change.
  // null: no access, or access revoked while the document was open.
});

Errors

SituationWhat the client sees
Missing, forged or expired tokenUpgrade refused with 401. Browsers hide the status: the load fails with CONNECTION_FAILED.
Valid token, no grant on the documentloadPulse rejects with FORBIDDEN; roleOf returns null.
Reader edits locallyThe upload is refused with FORBIDDEN, reported to onError; other clients never see the edit.
Management call without owner rightsHTTP 403 FORBIDDEN; 401 without valid credentials.