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.
Secrets
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.
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
| Role | Load and receive updates | Upload changes, create documents | Manage grants of the prefix |
|---|---|---|---|
| none | FORBIDDEN | — | — |
reader | yes | FORBIDDEN | no |
editor | yes | yes | no |
owner | yes | yes | yes |
A grant covers its prefix and everything below it. Grants only add access, and the strongest one wins. The subject * matches every authenticated user.
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.
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.
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:
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:
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
| Situation | What the client sees |
|---|---|
| Missing, forged or expired token | Upgrade refused with 401. Browsers hide the status: the load fails with CONNECTION_FAILED. |
| Valid token, no grant on the document | loadPulse rejects with FORBIDDEN; roleOf returns null. |
| Reader edits locally | The upload is refused with FORBIDDEN, reported to onError; other clients never see the edit. |
| Management call without owner rights | HTTP 403 FORBIDDEN; 401 without valid credentials. |