Skip to content

Permissions

Ramose decides, per fact, who may read it and who may change it. The rules live next to your schema, compile at deploy time, and run inside the database: a query returns fewer rows rather than trusting your UI to filter, and a forbidden write is refused rather than logged. This page walks Reef’s real policy; it is for anyone who has run the Quickstart.

Ramose verifies sign-in tokens; it never issues them. Bring Better Auth (Reef does), Clerk, Auth0, WorkOS — any provider that publishes signing keys.

The policy is a value: which roles may read, create, change or remove which fields, compiled to JSON for the server (glossary). You write it in TypeScript against your schema’s record types. The server enforces it on every read and write; your buttons are only a hint.

A role is a name carried in the sign-in token — the signed token the server verifies on every request (glossary). Rules refer to roles; the policy calls them classes (glossary).

rolemay
admineverything — bypasses every rule
memberread; create issues and comments; change or delete their own
viewerread only — every write is refused

Reef maps the invite’s role onto the token at sign-in — What the token carries.

Helper expressions first, then one rule block per record type:

examples/reef/src/domain/policy.ts:26-32
const anyone = P.or(P.class("admin"), P.class("member"), P.class("viewer"));
const editor = P.or(P.class("admin"), P.class("member"));
const admin = P.class("admin");
/** `member` may touch an issue they created; `admin` never reaches the rules. */
const ownIssue = P.and(P.class("member"), P.eq(Issue.creator, P.principal));
const ownComment = P.and(P.class("member"), P.eq(Comment.author, P.principal));
examples/reef/src/domain/policy.ts:34-48
export const policy = P.policy(Reef, {
principal: User.sub,
classes: CLASSES,
ns: {
user: {
read: P.allow(anyone),
// First entry into a workspace writes your own row; `sub` is preset from
// the token, so you cannot register as someone else.
create: P.allow(editor),
preset: [P.preset(User.sub, P.claims.sub)],
},
label: {
read: P.allow(anyone),
create: P.allow(editor),
},
examples/reef/src/domain/policy.ts:49-70
issue: {
read: P.allow(anyone),
create: P.allow(editor),
add: P.allow(ownIssue),
retract: P.allow(ownIssue),
retractEntity: P.allow(ownIssue),
preset: [P.preset(Issue.creator, P.principal)],
attrs: [
// …
P.attr(Issue.privateNote, { read: P.allow(admin) }),
],
},
comment: {
read: P.allow(anyone),
create: P.allow(editor),
retract: P.allow(ownComment),
retractEntity: P.allow(ownComment),
preset: [P.preset(Comment.author, P.principal)],
},
},
});

Read it as:

ruleplain English
principal: User.sub · classes: CLASSES“Who is calling” is the user record whose sub equals the token’s sub; the three roles a token may carry.
ownIssueI am a member and this issue’s creator is me. Admins never reach the rules.
issue.read: anyone · attrs: privateNote read: adminEveryone sees issues — except privateNote, which only admins receive. A screen that wants it must ask for it as .optional.
issue.add / retract / retractEntity: ownIssueMembers change or delete only their own issues; viewers never. Moving a card adds a status; the implied removal of the old one is what a viewer trips first: retract denied on :issue/status.
preset: creator = principalThe server stamps creator to the caller on create.
(nothing)Anything not mentioned is denied: no add arm on label, so members cannot recolour labels.

Deny by default. A record type or operation with no rule is invisible and unwritable (glossary). You never have to remember to lock something down; you have to remember to open it. Field rules narrow the record type’s rule, never widen it.

Reef’s buttons are polite; the proof is the server. Try it:

  1. bun run dev:reef, sign up, create a workspace, add the sample issues.
  2. Invite a second email as viewer.
  3. In a private window, sign up with that email, accept, open the board.
  4. Drag a card. It snaps back; a red toast reads retract denied on :issue/status.
Reef board as a viewer: header badge reads viewer, a red toast says retract denied on :issue/status, the dragged card is back in place
A viewer's drag, refused by the server; the toast is the error's own message. · src/app/screens/BoardScreen.tsx:241-245

What came back was Unauthorized (HTTP 403) with code: "policy" and attr: ":issue/status"; Reef’s useTransact({ onError }) turns it into the toast. Writes are checked twice: on arrival, where a refusal is Unauthorized, and again before a version number is assigned, where a refusal is TxRejected. The second check runs inside the writer, the one thing per database that commits writes (glossary), and it is the authority — handle both. Both are in Errors.

Open the same issue as an admin and as the member. Everything matches until the Admin note, which the member sees tagged masked and empty — that value never left the server:

The same Reef issue panel twice, headed ADMIN — note visible and MEMBER — note masked; only the Admin note differs, empty and tagged MASKED FOR MEMBER
One rule on privateNote is the whole difference; everything above it is identical. · src/domain/policy.ts:56-60

Reads do not fail. They shrink. A fact you may not read is absent — which is why Reef’s panel shape asks for it as privateNote: Issue.privateNote.optional (queries.ts:40-46). Had it been required, the whole row would disappear: pull resolves to null and a list query drops that record. That is deliberate — an error message that distinguishes “forbidden” from “does not exist” is itself a leak.

principal: User.sub says the signed-in user is the user record whose sub matches the token’s sub (glossary). Reef writes that record the first time an admin or member enters a workspace; viewers never need one, and until the record exists P.principal matches nothing.

P.preset(Issue.creator, P.principal) makes creator a server-filled field (glossary): on create the server stamps it to the caller. A client value identical to the preset is a no-op; a different one is refused. You cannot forge who created an issue.

Because the policy and the shapes your screens read are both values, Ramose.Policy.compile(policy, { pulls }) checks them against each other at deploy time — tighten a rule a screen depends on and the deploy fails, rather than that screen quietly emptying for one customer.

examples/reef/src/domain/policy.ts:76-77
export const compiledPolicy = (): string =>
P.compile(policy, { pulls: allShapes });
examples/reef/test/policy.test.ts:70-75
test("a masked attribute pulled as required is a compile error", () => {
const badShape = { note: Issue.privateNote };
expect(() =>
Ramose.Policy.compile(policy, { pulls: [...allShapes, badShape] }),
).toThrow(/privateNote/);
});

The check is opt-in: compile(policy) with no pulls skips it. Keep every shape your app reads in one list, as allShapes does.

The Ramose server picks its mode from two environment variables. (The server is the one Cloudflare Worker that serves all your databases; Ramose’s code calls it the peer — glossary.)

modeenvironmentwho gets in
Openneither seteveryone, as admin. Right for a laptop, wrong for the internet
Shared tokenRAMOSE_TOKENone bearer token, admin on every database — for a backend that is itself the authority
PolicyRAMOSE_POLICY (+ JWKS, the sign-in provider’s public keys — glossary)every caller presents a sign-in token; reads are filtered and writes checked per fact

Setting RAMOSE_POLICY changes what RAMOSE_TOKEN means: its holder gets a role no rule can name — the surprise behind “my token stopped working when I turned on permissions”.

  1. Write policy.ts next to your schema and compile it with your shapes.
  2. Put it on the server with Ramose.authEnv({ policy, jwksUrl, auth }) in the deploy file — Deploy.
  3. Mint tokens carrying a role and a database name — Sign in and roles.