Access Control (`access.js`) — permission design
prompts/pkg/llms/access.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
This doc is in view because the app is getting its access.js: a new app's first build writes its rules with its screens, and a later edit adds or changes them. Either way the records carry their keys (creatorHandle on the object, the parent id on each child, authorHandle on every record), and this build writes the rules that key on them. The rules and those keys are plumbing with nothing on screen of their own; the invite screen joins the build when the ask names other people, sharing, a group or a person by name, and when the app is a person's own collection it is the first screen's add-a-friend control, since adding a friend is how a collection is shared; otherwise it is offered as an option after the build. Every app gets record rules, and the group shape taught below is the resting default. A person the ask names is let in by a record — see A person the ask names is let in by a record below — so the build states the membership record's shape and the invite handler that writes it.
When an edit adds the rules to an app that already exists, the edit owes only what the rules need. Read App.jsx for the object type, the field that names its creator and the parent id its children carry; name the channels from those records; write the complete access.js; then change App.jsx only where the rules require it — stable creator membership, typed content queries, any write surface not yet gated on can, and the invite screen when the ask names other people, sharing or a group — with SEARCH/REPLACE edits. A file the rules do not touch is not emitted, and stays as it is.
Honest copy: until access.js says otherwise, all app data is shared and world-readable, and the runtime, not your UI, decides access — access.js is where per-document write validation and channel-based read isolation live. Write UI copy that describes what the rules actually do.
access.js is a separate file alongside App.jsx; each named export gates the database of the same name (export function chat(...) gates useFireproof("chat")), and an export default acts as a catch-all. The App.jsx you write gates its write surfaces on useVibe(dbName).can — the same function this access.js enforces server-side.
Construct the document an action will save, check that same value with can.create or
can.edit, then write it to the same database. Include every field the access rule checks,
including the intended next status. For example, cancelling a reservation constructs
const next = { ...reservation, status: "cancelled" }, checks can.edit(next).ok, and
saves next. A creation control checks the complete creation draft. On a denial render a
sentence in the app's own words — reason is a machine token that informs that sentence —
and keep loading and signed-out states distinct from denial. Name the invite screen in the
app's world — a party board says "Invite a friend" while a team workspace keeps the fitting
"Invite Collaborator" — and shape the denial for who is reading it: a signed-out visitor gets
the contextual sign-in invitation ("Sign in to add your roast", wired to requestLogin()); a
signed-in viewer outside the membership gets the membership explanation, not another sign-in
button. A playful occasion never widens the audience — the rules still decide who reads and
writes.
Reference
Function signature
(doc, oldDoc, user: UserContext | null, ctx: Helpers) => AccessDescriptor where doc is the document being written, oldDoc is the previous version (null for new documents), user is the authenticated user or null for anonymous requests, and ctx provides server helpers for checking materialized state.
UserContext: { userHandle: string, displayName?: string, backend?: "scheduled" | "onChange" | "fetch" } — userHandle is stable unique id (use for all auth checks), displayName is display only (never use for identity checks), and backend is present only when the app's own backend.js issued this write, naming the handler that did (scheduled, onChange or fetch). Every write from a browser or the CLI arrives with backend absent, the app owner's own writes included — it says where a write came from, never who the writer is. Gate on ctx.requireBackend(...) rather than throwing on user.backend yourself: a bare throw on user.backend refuses the hand-repair lane admin mode exists for, and returns no descriptor for the write it refused.
Helpers (ctx): Opaque closures over the materialized grant state. They throw or pass — you cannot enumerate channels, list members, or iterate grants. They also throw when user is null: ctx.requireAccess(channelId) throws if user is not in the channel, ctx.requireRole(roleName) throws if user is not in the role, and ctx.requireBackend() throws unless the app's own backend.js issued this write — ctx.requireBackend("scheduled") narrows that to one handler. All three no-op under admin mode.
Express every refusal about who may act through the supplied validators — ctx.requireAccess, ctx.requireRole, ctx.requireBackend — so that admin mode passes them and the function still runs, applying your own channels and grants to the repaired document; a refusal about the document's own shape, like an unknown type or a missing field, stays a plain throw and is right to, while a write-once check sits in between and is worth naming in a comment, since a schema repair has to get past it.
A component-owned image database (media:<hostDbName>) uses platform-authored rules by default: each generated image is readable by whoever can read the record it was generated for, and by the person who generated it. An explicit string-named export such as export { posterMedia as "media:posters" } overrides that one media database. Neither export default nor a function merely named media overrides it. Your function governs your app's own records, and every picture <ImgGen> makes is saved in that media database as its creator's own record, so pictures need no branch of their own in yours.
AccessDescriptor return type
A stored document must be routed to at least one channel (channels) to be readable — a result with no channels is refused at write time ("unreadable write"). To reject a write outright, throw { forbidden: "reason" }.
tstype AccessDescriptor = {
channels?: string[]; // route this doc to channels
members?: Record<roleName, userHandle[]>; // role membership (reduced by union)
grant?: {
users?: Record<userHandle, string[]>; // direct user → channel grants (reduced by union)
roles?: Record<roleName, string[]>; // role → channel grants (reduced by union)
public?: string[]; // member-public read — any member, no channel grant needed
};
audience?: { followersOf: userHandle } | { mutualsOf: userHandle }; // platform follow-graph reach
expiry?: string | number | null; // ISO date or unix seconds
allowAnonymous?: boolean; // opt-in for null-user writes
};
audience is the one field resolved against the PLATFORM follow graph instead of this app's own grants: add it to an otherwise normal result, always keyed on the live writer (audience: { followersOf: user.userHandle }), and the platform decides at read time who that reaches. The arm you emit is only a ceiling — the subject's own visibility setting for this app picks the actual rung (off / their followers / anyone at all), and it can shrink that reach but never grow it. The full teaching — which arm, the three-rung ladder, the useSocial() / useInterest() surfaces, and the copy rules that go with them — is in the social section of the system prompt; don't re-derive it here.
Person or place: audience or grant
The question that decides which one a doc wants is whose thing it is: personal content belongs to the PERSON; a contribution to a shared artifact belongs to the PLACE. Someone's picks, favorites, badges, or profile items are the person's — give them an audience arm keyed on their own handle, so the reach tracks that person's settings and follows them everywhere. Guestbook entries, posts on a communal wall, items on a shared board are the place's — route them to the place's channel with grant.public or explicit grants exactly as the rest of this doc shows, unaffected by the author's later profile flips: a guestbook entry doesn't vanish because the signer went private.
Two controls: a followers checkbox and an individual buddy picker
A privacy-sensitive app — a workout log, a journal, a health or money tracker — shares nothing until the person says so, and its sharing surface is exactly two visually distinct controls, because they run on two different mechanisms: the PLATFORM's follower list, and the APP's own grant list. Build both controls in every privacy-sensitive app unless the person asked, in their own words, to keep it to themselves — a sensitive domain is the reason the controls start off, never a reason to leave them out.
- A "Shared with followers" checkbox — the platform half. It starts UNCHECKED in a privacy-sensitive app and the app must never pre-check it or arm it on mount; checking the box IS the consent, so call
requestFollowersAccess()from that one change handler and nowhere else. The box is a real two-way switch, so the same handler must branch on the new checked state and turn sharing back OFF withsetVisibility("private")when it is unchecked — a handler that calls the consent verb on both edges is an enable-only button that can never be undone. Make the word "followers" in the label tappable and have it preview the viewer's real follower list fromuseSocial().followers— that list is platform-wide, already established, and the same in every app, which is exactly why the box cannot start checked. - An "Add buddies" individual picker — the app half, named for the app's own domain (workout buddies, reading buddies, a care circle). The person picks individuals by handle, and adding an individual buddy writes a share doc grant, never a change to the follow graph: the access fn returns
grant: { users: { [doc.buddyHandle]: [ch] } }for that one chosen handle. It is follow-independent — a buddy need not follow you, adding one never checks the followers box, and this list belongs to the app.
Keep the two visually distinct (a checkbox with its own explanatory line; a separately labelled list with an add-a-handle field below it), and never let one write the other's state. Re-render both from what actually took effect — followersEnabled after the await, the share docs the app has written — never from what was asked for. Unchecking the box (setVisibility("private")) or removing a buddy means that person stops receiving new data — never write copy promising their existing copies are erased, or that they can no longer see what they already have.
When the owner asks you to add publish or sharing to a privacy-sensitive app, build exactly this consent-first shape and nothing wider. The two controls above for follower and buddy sharing, or a per-item publish behind one tap for a single finished thing (the per-item channel whose read-grant flips from a visibility field); never an always-on grant.public channel for the records themselves, never a box that starts checked or a verb called on mount, and no "Anyone" rung — the platform refuses a "public" level on a sensitive app, so that control would be a button that does nothing. The sensitivity mark itself is not yours to change from code: if the ask is really "this app is not sensitive", say so and point at the app's settings, where the owner makes that call.
App.jsx — the checkbox is the consent call; the picker is a doc write:
App.jsx
jsxconst { can } = useVibe("logs"); // the write gate is always the server's own verdict
// checking is the consent preflight; unchecking is the off switch. Never one verb for both edges.
<Row gap={2} align="center">
<Checkbox
label="Shared with followers"
checked={followersEnabled}
onChange={(checked) => (checked === true ? requestFollowersAccess() : setVisibility("private"))}
/>
<Button type="button" size="sm" variant="quiet" onClick={() => setShowFollowers(true)}>
See followers
</Button>
</Row>
{showFollowers && <Stack gap={1}>{followers.map((f) => <Text key={f.handle}>{f.handle}</Text>)}</Stack>}
{/* the app's own list — one share doc per person picked */}
{can.create({ type: "share", authorHandle: me?.userHandle, buddyHandle: "x" }).ok && (
<AddBuddyForm
onAdd={(buddyHandle) => database.put({ type: "share", authorHandle: me?.userHandle, buddyHandle })}
/>
)}
access.js — the buddy grant and the followers audience arm are independent:
access.js
jsconst ch = `log:${user.userHandle}`;
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
if (doc.type === "share") {
// app-owned: grant the ONE handle this person picked (plus themselves).
if (doc.authorHandle !== user.userHandle) throw { forbidden: "your own log" };
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own log" }; // an update stays its writer's
return { channels: [ch], grant: { users: { [doc.buddyHandle]: [ch], [user.userHandle]: [ch] } } };
}
if (doc.type === "entry") {
// platform-owned: the audience arm; the checkbox above is what arms it.
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" }; // an update stays its writer's
return {
channels: [ch],
grant: { users: { [user.userHandle]: [ch] } },
audience: { followersOf: user.userHandle },
};
}
throw { forbidden: "unknown document type" }; // every access.js ends by denying what it does not know
// Summary: each person's workout log is theirs, shared with the buddies they pick and, once they say so, with their followers, and only its writer changes a record. Every record carries its writer's handle in authorHandle, and a share names the buddy in buddyHandle.
Private generated images and published snapshots
<ImgGen database="posters" _id={poster._id}> stores its generated images AND
prompts in media:posters. By default each image follows the record it was
generated for: whoever can read poster can read its images, and the person who
generated an image can always read it. Make the posters records private and
their images are seen only by each poster's readers and by whoever generated
each image; make a poster public and its images are public. No media rule is
needed for that.
Write a media override only when the images should be more open or more closed than their host record — for example, drafts that only their creator sees even on a shared poster. An exact string-named media export replaces the default for that one media database:
jsfunction posterMedia(doc, oldDoc, user) {
if (!user || doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
if (doc.type !== "version" && doc.type !== "pointer") throw { forbidden: "unknown media record" };
if (oldDoc && (doc.authorHandle !== oldDoc.authorHandle || doc.hostDocId !== oldDoc.hostDocId || doc.type !== oldDoc.type)) {
throw { forbidden: "creator and target cannot change" };
}
if (oldDoc && oldDoc.type === "version") throw { forbidden: "versions are append-only" };
if (doc.type === "version" && (!doc._files || typeof doc._files !== "object" || Array.isArray(doc._files) || Object.keys(doc._files).length === 0)) {
throw { forbidden: "a version needs its generated file" };
}
const channel = `images:${doc.authorHandle}`;
return { channels: [channel], grant: { users: { [doc.authorHandle]: [channel] } } };
}
export { posterMedia as "media:posters" };
// Summary: each person's generated poster images are theirs alone to see, and every version stays as it was made. A media record carries its writer's handle in authorHandle and names its poster in hostDocId.
Each writer's versions are private even if another writer guesses the same
hostDocId; guessing never grants access to another person's image records.
An override that should keep following its host's current read rules
returns inheritRead: { db: "posters", docId: doc.hostDocId } — the shape the
default already uses. Bind writes to the intended creator too: assign host IDs
such as ${me.userHandle}:${crypto.randomUUID()}, enforce that creator prefix
on host creation, and require the same prefix on doc.hostDocId in the media
rule. inheritRead controls reads; by itself it does not authorize writes to
the host.
To publish one finished image while keeping drafts private, read the chosen
version with useImgGen, obtain its _files[version.id].file(), and save that
File with the event facts in a separate publication record. Give only that
record a public grant, and render its _files.poster.url with <img> for
visitors. Do not spread the draft or media document into the publication: that
would copy draft prompts and prior versions. Unpublishing removes that record's
public grant. Explicit media bindings fail closed on older access outputs until
the publish-time backfill evaluates them under the new rule.
Pictures on shared records
Anyone who can read a record can make and keep pictures for it: each generation is saved in the media database as that person's own record, so a shared record stays author-edited while everyone's pictures land. Write-once docs can simply if (oldDoc === null) {} else throw.
_id strategy matters
Documents that represent a unique named resource (channels, user profiles, config singletons, starter content) should use a deterministic _id with a short prefix — "ch:" + name, "profile:" + handle, "config", "starter:" + key. This enforces uniqueness: two users creating "general" get the same doc, not two — and a first-load write that runs again (a fresh tab, another device) overwrites the same starter docs instead of duplicating them. Records a request says the app must already have are created this way, by the app's first render; see "Required starting records" in fireproof.md. Documents that represent events or content (messages, posts, survey responses) should let _id be auto-generated — each one is unique by nature. A resource doc's channel is computed from the record itself — its own prefixed _id, or board:${doc._id} — and a content doc carries that object's id as a foreign key and routes to the same computed name.
Grants are additive
The effective access state is the union of every current document's AccessDescriptor output. There is no "remove grant" operation — deleting a document drops its contribution from the union automatically. This makes revocation trivial: delete the document that granted access, and the grant disappears on next sync.
Grant resolution order: the server resolves per-user channel access in two passes — first expand grant.roles through members, then union with grant.users direct grants.
allowAnonymous
If user is null and the function returns without throwing, the runtime checks allowAnonymous. If absent or false, the write is rejected. This prevents a function that never inspects user from silently opening anonymous writes. When user is not null, allowAnonymous has no effect. grant.public makes channels readable by any member (anyone through the door) without a specific channel grant — whether non-members can also read depends on the app-level public toggle. Anonymous write requires allowAnonymous: true separately. Declaring it is a proposal, not the last word: an anonymous write that lands in shared storage also needs the deployment's own owner-visible anonymous-writes setting to admit it, and the platform surfaces your declaration to the owner as the request to turn it on.
Choosing channels — keep the count low
A channel is a reusable unit of read access: grant a user into a channel once and they can read every document routed there. Reach for the smallest number of channels the sharing actually requires.
- A reusable group reads many docs (a team, a board, a project): route to one channel the collaboration owns —
return { channels: [doc.channelId] }— and grant membership once via a meta or invite doc. Many documents share the one channel. That channel's name is computed from the record the collaboration lives on —board:${doc._id}on the object itself andboard:${doc.boardId}on its children — and the branch that creates the object grants its creator that channel in the samereturn, so the person who makes a thing is already inside it and their very first write lands. An invite doc for that collaboration grants the picked handle that same computed channel, so being let into one object is being let into that one object, and the next group to open the app gets a room of its own. - Only the author reads it (private notes, a user's own uploads): route to one channel the user owns.
const mine = \user:${user.userHandle}`; return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };` — all of that user's private documents live in this single channel. - A document goes to a one-off set with no reusable group: route to several channels at once —
return { channels: [\user:${aHandle}`, `user:${bHandle}`] }. Mint a per-document channel (channels: [doc._id]`) only when each document genuinely has its own disjoint audience. - Refusing a write:
throw { forbidden: "reason" }. Every document you store is routed to at least one channel so it can be read back.
Content queries select a record type and its parent
A shared channel contains the group's content and its membership records. An
invitation may carry the same boardId as a gear item, so querying only boardId
returns both. When adding invitations or other supporting record types, update
the existing content query at the same time. Keep the parent selection and derive
one content-only list:
jsxconst { docs: boardDocs } = useLiveQuery("boardId", { key: boardId });
const gear = boardDocs.filter((doc) => doc.type === "gear");
Use that typed list for rendered rows, counts, empty states, correction targets,
and records sent to Ask. Membership views select their own record types from the
same group. Match the app's actual types and parent field; a compound index that
selects both is equally suitable. This is a content selection rule: access.js
continues to decide who can read and change each record.
Multiple databases in one file
Each named export gates its own database. A single /access.js can gate all databases the app uses:
jsexport function chat(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "authentication required" };
if (oldDoc && oldDoc.channelId !== doc.channelId) throw { forbidden: "a record stays in its channel" };
ctx.requireAccess(doc.channelId);
return { channels: [doc.channelId] };
}
export function notes(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "authentication required" };
// Private to the author — one channel per user.
const mine = `user:${user.userHandle}`;
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
}
// Summary: members of a channel read, post and edit everything in its chat, and each person's notes are theirs alone. A chat record names its channel in channelId.
Databases without a matching named export fall through to export default if one exists. If there is no default export either, the database uses the default app-level permissions (no access function).
Hyphenated database names are rare — prefer camelCase (useFireproof("crewChat")). If you inherit a hyphenated name, use export { localName as "db-name" } to map a local function:
jsfunction crewChat(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "authentication required" };
if (oldDoc && oldDoc.channelId !== doc.channelId) throw { forbidden: "a record stays in its channel" };
ctx.requireAccess(doc.channelId);
return { channels: [doc.channelId] };
}
export { crewChat as "crew-chat" };
// Summary: members of a channel read, post and edit everything in its crew chat. A record names its channel in channelId.
Catch-all with export default
Use export default to gate every database without writing a named export for each one. Named exports (including as exports) still take precedence for databases that need custom logic:
jsexport function chat(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "authentication required" };
if (oldDoc && oldDoc.channelId !== doc.channelId) throw { forbidden: "a record stays in its channel" };
ctx.requireAccess(doc.channelId);
return { channels: [doc.channelId] };
}
// Everything else: authenticated users get a private per-user channel
export default function (doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "authentication required" };
const mine = `user:${user.userHandle}`;
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
}
// Summary: members of a channel read, post and edit everything in its chat, and everything else each person writes is theirs alone. A chat record names its channel in channelId.
This is especially useful when an app has many databases.
Common oldDoc patterns
Use oldDoc (the previous version of the document) to enforce invariants across updates: if (oldDoc === null) { /* create-only logic */ } for new documents, and if (oldDoc && doc.version <= oldDoc.version) { throw { forbidden: "version must increase" } } for monotonic versions.
An access function that dispatches on doc.type keeps each record's type: right after the sign-in check, if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };. The branch that runs is chosen by the record being written, so this one line is what keeps an edit from rewriting someone else's record as another kind and skipping the checks its own kind applies. Deletes pass it: the delete gate hands the function the stored record, type and all, marked _deleted. The worked examples here that dispatch on more than one type all enforce it.
Documents only the app's own backend writes
Some documents belong to the machinery rather than to a person: a scheduled run's state doc, a counter a scheduled sweep advances, a notification the backend generates. Gate those on the origin with ctx.requireBackend(...) — one line, no conditional:
access.js
jsif (doc.type === "runState") {
ctx.requireBackend("scheduled");
return { channels: ["show"] };
}
The owner-handle version of this rule looks equivalent and is not. The person holding the owner handle sits in front of the app in a browser, so a rule that admits them admits a hand-written runState doc typed from the UI — it keeps strangers out of the run's memory and lets in the one person most likely to be poking at it. ctx.requireBackend("scheduled") admits the scheduled run and turns every ordinary client write away.
This is not a wall against the app's own maintainer. ctx.requireBackend no-ops under admin mode, the same way ctx.requireAccess and ctx.requireRole do, so the owner or platform support repairing a document by hand passes the gate — and the function still runs, so the repaired doc gets the app's own channels and grants and lands routed. That is the point: a document nobody can hand-fix is a support dead end.
Name the handler. ctx.requireBackend() with no argument admits any of the three, and one of them is fetch — a route any visitor can call. A bare ctx.requireBackend() on a run's state doc therefore reads as "only my machinery" while admitting anyone who can reach your _api endpoint, so name the handler you mean unless you genuinely accept all three.
One trap: applying a release's seed.json is the platform writing, not the app's backend.js, so a seeded document arrives with backend absent and ctx.requireBackend turns it away. Seed that type under a permissive rule, then tighten it once the app is running — or leave the machinery's own documents out of the seed entirely, which is usually what they want.
When to emit access.js, and where it goes
The placement of access.js depends on which turn shape you are in — one-shot whole-app generation vs. incremental scaffold/follow-up edits. Follow the subsection that matches your turn; the other one's ordering is wrong for you and will strand writes.
Variant-neutral rules (always): write access.js as a complete fenced block with comments explaining the permission model — what each doc type does, who can write it, what channels/roles it creates. End the file with its // Summary: line (see The // Summary: line below). Never put access function code inside an App.jsx block — it will overwrite the React component; the path heading (e.g. access.js vs App.jsx) is how the system knows which file to write. Whatever the turn shape, App.jsx gates its write surfaces on useVibe(dbName).can — the same rules this access function enforces.
One-shot generation (the whole app in one full-file block): emit access.js as the last file of the turn — after the complete App.jsx and any companion feature files. You are NOT emitting incremental SEARCH/REPLACE edits here — write the finished files in full. The worked examples below show the access-function shapes; ignore any edit-by-edit cadence in them and emit the access function as one complete block.
Scaffold or follow-up turns (incremental SEARCH/REPLACE edits): emit access.js early — for a fresh scaffold, right after the shell and before any feature edits; on a follow-up turn, before any App.jsx edit that writes a doc type it gates. Ordering matters here because the app runs live while edits stream in: a db.put of a doc type the in-force access.js still rejects fails immediately with unknown document type, so the access branch must land before the write. Emitting it first also commits the permission design so every subsequent edit can gate its write surfaces on useVibe(dbName).can.
Worked example — members-only chat writes
access.js
jsexport function chat(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "authentication required" };
if (doc.type === "message") {
// Author fixed at create and kept for good; the author edits their own.
if (oldDoc === null) {
if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
} else if (doc.userHandle !== oldDoc.userHandle) {
throw { forbidden: "cannot change author" };
} else if (oldDoc.userHandle !== user.userHandle) {
throw { forbidden: "not author" };
}
ctx.requireAccess(doc.channelId);
return { channels: [doc.channelId] };
}
throw { forbidden: "unknown document type" };
}
// Summary: members of a channel read and post its messages, and each author edits their own. A message carries its writer's handle in userHandle and its channel in channelId.
ctx.requireAccess(channel) gates on channel membership (a grant.users/grant.roles grant), NOT on grant.public, which is read-only — so a channel anyone signed-in should post to must not gate writes on requireAccess (it would block every non-owner); just check the author and route the doc. Reserve requireAccess for members-only channels whose writers you granted membership. For writes that need no sign-in at all ("anyone can sign/submit"), return allowAnonymous: true instead of throwing on !user. A grant/member/role doc must also return channels (route it to an owner-readable admin channel like ["admin:grants"]) — a channel-less result is rejected as "unreadable write". On updates, keep ownership immutable (if (oldDoc && doc.<authorField> !== oldDoc.<authorField>) throw, where <authorField> is your doc's author field — authorHandle/userHandle/senderHandle) and let the author edit their own (as chat.message above shows). Pictures other viewers make for a shared doc are their own records in the media database, so an author-only update keeps every picture flowing.
A follow-up edit that adds a NEW doc type updates access.js FIRST. The app runs live while your edits stream in, and writes are enforced against the access.js that was in force before this turn until the whole turn completes — so a db.put of a doc type the old function rejects fails immediately (unknown document type), even though your access.js update lands later in the same reply. Emit the access.js edit adding the new type's branch before the App.jsx edits that write it. And never fire-and-forget background writes of a newly added type: gate seed/auto-write effects on useVibe(dbName) — if (!ready || !can.create(sampleDoc).ok) return; with ready/can in the effect deps, checking a representative sample of each doc type the effect writes — so a not-yet-allowed write is skipped quietly instead of surfacing rejection errors the user didn't cause.
The // Summary: line — the rules in one plain sentence
Every access.js ends with one plain-language summary line after the function: the file closes with a comment starting // Summary:, and nothing follows it (a long one may wrap onto further // lines). In one or two sentences in the app's own nouns, it says what people can do — who can see and change what — and what a record needs to be admitted: the field that carries its writer's handle, and the state a new record starts in:
js// Summary: each person's recipes are theirs alone; a friend added to a recipe can read it. A recipe carries its writer's handle in authorHandle; orders start with status "new".
Name each field exactly as the function reads it — the line is how the person, and the platform, read the rules at a glance. Whenever this turn changes the function, rewrite the summary in the same block so it describes the rules as they now stand. The seed's records follow the line: each one carries "@owner" in the writer field it names and starts in the state it names. Every worked example below ends with its own.
Reconciling seed.json when you write or change access.js
An app usually gets its seed.json on an earlier turn than its access.js. So when you add or change an access function, you are almost always meeting a seed file that was written before any of these rules existed — and the seed is yours to bring into line, not the other way round. The access function is the constraint; the seed is a sample of the data. When a sample violates a constraint, change the sample.
This matters beyond the import itself. The app's seed.json stays in your context on every later turn, so it is what a future turn reads to learn the app's document shape. A seed that disagrees with the access function teaches the wrong shape to every turn that follows.
So, in the same turn as the access edit and after it:
- Re-emit
seed.jsononly when it does not fit the function. Drop items whosetypethe function does not authorize, and add to each remaining item the field its branch actually routes on — the channel-determining id, the author handle, whatever anaudiencereads. A seed item missing that field is deniedunknown document typewhen the owner applies the import, and the same gap resurfaces as a hard error the moment the running app writes that type. - Never add a branch to
access.jsjust to admit a seed item. A branch exists because the app writes that type. If a seed item's type appears nowhere in the app's own code, delete the item — do not widen the permission model to let example data through. - Keep every explicit
_idbyte-identical. Seed items that other items reference, and well-known singletons (config:profile,settings:sharing), carry an explicit_idthat the app's code matches literally. Renaming one during a reconcile breaks that lookup inApp.jsxand orphans any document an owner already imported, in a single edit. Keep thekeyslugs stable for the same reason — the platform derives idempotency from them.
Write the seed as its writer: the owner. Applying the import writes every record as the owner, with user = { userHandle: <the owner's handle>, isOwner: true }, and stamps nothing on the record. So a field a branch compares to the writer — authorHandle, creatorHandle, hostHandle — carries "@owner", and the platform fills in the handle of whoever applies the seed: { "key": "pancakes", "type": "recipe", "title": "Sunday pancakes", "authorHandle": "@owner" } is the owner's own record in the app you build and in every remix of it. Write the marker as the whole value, exactly "@owner". Where the rules set the state a new record starts in ("orders start as new"), each seeded record starts in that state.
Order the seed so the function admits it. Applying the import runs every document through access.js as the owner's first write, in file order, so the seed passes its own rules or it does not arrive at all. A document whose branch reads a channel off a parent needs that parent earlier in the file, granting the channel the child will be routed into; a type the function routes on a field carries that field on every item. A task list seeded as { type: "task", listId: "main" } needs a list item with _id: "main" above it whose branch grants list:main — without it every task is refused on the first apply, and the app opens empty with nothing on screen to say why.
If the reconcile removes every item, still emit seed.json, containing {}. Omitting the file does NOT delete it — a file your turn does not touch is carried forward from the previous version verbatim, so leaving seed.json out preserves exactly the stale, unauthorized seed you were removing. An empty object is a valid seed and replaces it.
Ending the function: terminal denial (the default) vs. the discard channel
Every access function ends by handling the doc types it knows and denying everything else. The default ending — closing every worked example below — is the terminal throw { forbidden: "unknown document type" }: fail-closed, and loud. If the app later writes a type nobody added a branch for, the write is rejected with a visible error, so type-enumeration drift surfaces instead of silently corrupting the data model. Reach for the throw unless you have a specific, named reason not to.
The one documented alternative is the discard channel — return { channels: ["discard"], grant: {} }: accept the unknown-type write into a channel that has no grant, so it persists but nothing can ever read it back. This is deny-by-unreadability, not deny-by-error — the write silently succeeds and then vanishes, so the user sees neither a failure nor the data. That trade-off makes it the wrong default: a silent swallow hides real bugs the throw would have caught. Use it only where a visible failure is worse than a silent one. The canonical case is the anonymous-local sign-in migration: when an anonymous visitor signs in, their device-local docs migrate into the cloud db in bulk, and a single stray legacy doc type hitting a terminal throw would fail the whole migration and surface error toasts on first sign-in. Routing the unknown remainder to discard lets the known docs migrate while the legacy straggler is swallowed instead of breaking the flow. Outside that kind of bulk/legacy-migration path, keep the throw. (Note the shape difference the runtime cares about: grant: {} grants no reader, so the write is unreadable — deny. A permissive fallthrough that returns a readable channel for unknown types, e.g. grant: { public: [...] }, is fail-open and is the exact bug both endings exist to prevent.)
Worked examples — permission design
Each example below closes its type dispatch with the default terminal throw { forbidden: "unknown document type" }; substitute the discard-channel fallthrough above only for an app on the bulk/legacy-migration path that documents why.
Worked example — open channel wall (author-owned writes)
access.js
jsexport function wall(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
if (doc.type === "channel") {
ctx.requireRole("owner");
return { channels: [doc._id], grant: { public: [doc._id] } };
}
if (doc.type === "post") {
// Author fixed at create and kept for good; the author edits their own.
if (oldDoc === null) {
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
} else if (doc.authorHandle !== oldDoc.authorHandle) {
throw { forbidden: "cannot change author" };
} else if (oldDoc.authorHandle !== user.userHandle) {
throw { forbidden: "not author" };
}
return { channels: [doc.channelId] };
}
throw { forbidden: "unknown document type" };
}
// Summary: the owner opens channels everyone can read, and anyone signed in posts to them and edits their own posts. A post carries its writer's handle in authorHandle and its channel in channelId.
Worked example — per-object collaboration with join request
access.js
jsexport function board(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
if (doc.type === "board") {
// A board's channel comes from its own _id, so every new board record opens a room of its own.
const own = `board:${doc._id}`;
if (!oldDoc) {
// The person who starts a board is its author, and they are inside it from the first write.
if (doc.author !== user.userHandle) throw { forbidden: "start your own board" };
} else {
if (doc.author !== oldDoc.author) throw { forbidden: "creator is fixed" }; // write-once
ctx.requireAccess(own); // its members rename and edit the board they are in
}
return { channels: [own], grant: { users: { [doc.author]: [own] } } };
}
// Everything else names its board in boardId and stays on that board.
if (oldDoc && oldDoc.boardId !== doc.boardId) throw { forbidden: "a record stays on its board" };
const channel = `board:${doc.boardId}`;
if (doc.type === "share") {
ctx.requireAccess(channel); // a member lets a friend in
return { channels: [channel], grant: { users: { [doc.invitee]: [channel] } } };
}
if (doc.type === "request") {
// Anyone signed in asks to join for themselves; members read the ask and answer it with a share.
if (doc.requester !== user.userHandle || (oldDoc && oldDoc.requester !== user.userHandle)) {
throw { forbidden: "ask for yourself" };
}
return { channels: [channel] };
}
if (doc.type === "item") {
ctx.requireAccess(channel); // any member adds and edits every item on the board
return { channels: [channel] };
}
throw { forbidden: "unknown document type" };
}
// Summary: anyone signed in starts their own board, its members let a friend in with a share and add and edit every item on it, and anyone signed in can ask to join. A board carries its creator's handle in author, a request its asker's handle in requester, and every other record names its board in boardId.
ctx.requireAccess(channel) gates on membership (a grant.users/grant.roles grant), not grant.public (read-only) — so an open feed anyone signed-in may post to must not gate writes on it; check the author and route the doc. For writes needing no sign-in ("anyone can sign/submit"), return allowAnonymous: true instead of throwing on !user. A grant/share/request doc must also return channels — a channel-less result is rejected. On updates, keep ownership immutable (doc.authorHandle !== oldDoc.authorHandle → throw) and let the author edit their own (as wall.post above shows). Pictures other viewers make for a shared doc are their own records in the media database, so an author-only update keeps every picture flowing.
The sharing rule. Each person's own records live on a channel named by their handle, granted on every write they make, so the app opens on their tree with nothing to create first; a friend joins by a member record the tree's owner writes. A second or shared thing is its own document, written by the person's own action (a tap, a submit, a pick), with a type access.js has a branch for, carrying the creator's handle and naming the channel its records route to. App-wide authority is a role the owner holds from the seed and hands out with a role record; rules check ctx.requireRole, never user.isOwner. The notes example below is the tree, A second or shared object is the next node, and App-wide authority is a role the owner hands out is the third. A tree's name or settings is a settings record (_id: "settings-" + handle) written only when the person changes them: its control checks can.create on the complete record while none is saved and can.edit on the saved record spread with the change once one is. A person's own action — a tap, a submit, a pick — is what starts a write; nothing writes on load or on mount.
Build the permission model around what a newcomer should be able to do. When a stranger opens the app, they should immediately be able to do the thing it's for — add their own entries, post a note, drop a pin, join a shared canvas. So the default is: every signed-in visitor is a first-class participant who creates their own objects and edits what they created (doc.authorHandle === user.userHandle, checking oldDoc on updates), from first load, with no one needing to let them in.
Every app ships access.js. When the person says "just for me", "no sharing" or "keep it single-user", each signed-in person gets their own collection, private to them, and the way to share it is adding a friend, who then sees the collection. That is the private per-user shape below (the notes example, whose member branch is the add-a-friend control's write): the person's tree is the channel named by their handle, and the add-a-friend control writes the member doc that grants the friend that channel. The rules key on the writer's own handle, so they read the same for the person who made the app and for anyone they invite later. The app's nature is sensitive, and who can open the app is its sharing setting, not the rules. A piece made for readers keeps its own publish step instead — a sketch drafted for a gallery, a post, an article — on the per-item channel this example shows, whose read-grant flips from a visibility field: a published piece is world-readable in access.js and reaches whoever can open the app — its members while it stays private, everyone once the owner opens it up. Publish copy says that plainly ("Published — everyone who can open this app sees it"):
access.js
jsexport function sketches(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
if (doc.type === "sketch") {
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
const mine = `user:${user.userHandle}`; // this person's own tree
const ch = `sketch:${doc._id}`; // the item's own channel
const grant = { users: { [user.userHandle]: [mine, ch] } };
if (doc.visibility === "public") grant.public = [ch]; // published: everyone who can open the app
return { channels: [mine, ch], grant };
}
throw { forbidden: "unknown document type" };
}
// Summary: each person's sketches are their own, and a sketch they publish is open to everyone who can open the app. A sketch carries its writer's handle in authorHandle and starts unpublished.
An ask for "no access control" or "anyone can edit" ships access.js too, with rules that admit everyone: every signed-in visitor writes the shared records and edits their own, and allowAnonymous: true opens the same writes to guests when the ask needs no sign-in. When an ask says both, keeping it to themselves decides.
When the person asks for private per-user permissions — "each person sees only their own", "private to every user" — every visitor gets their own private space on a single per-user channel. A sleep log, a houseplant watering schedule, a savings jar, a notes app or a workout log where the data is one person's own routes every doc that visitor creates to the one channel keyed on their handle — user:${user.userHandle} — self-granted so only they read it, with authorHandle: user.userHandle fixed at create and held immutable on update (oldDoc.authorHandle === user.userHandle). Their whole collection lives on that single private channel, reachable from first load, each visitor's space entirely their own:
access.js
jsexport function notes(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
const ch = `user:${user.userHandle}`; // this visitor's own private space
if (doc.type === "note") {
if (!oldDoc) {
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
} else {
if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "cannot change author" };
if (oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
}
return { channels: [ch], grant: { users: { [user.userHandle]: [ch] } } };
}
if (doc.type === "member") {
// the add-a-friend control's write: the named friend reads this person's whole collection.
// A membership stays its writer's, and who it names is fixed, so a friend who can read these
// records rewrites or removes none of them.
if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
if (oldDoc && (oldDoc.addedBy !== user.userHandle || doc.userHandle !== oldDoc.userHandle)) throw { forbidden: "a membership is its writer's" };
return { channels: [ch], grant: { users: { [user.userHandle]: [ch], [doc.userHandle]: [ch] } } };
}
throw { forbidden: "unknown document type" };
}
// Summary: each person's notes are theirs alone, private from the first one they write, and a friend they add reads their notes. A note carries its writer's handle in authorHandle; a member record names the friend in userHandle and who added them in addedBy.
Every item a visitor creates lands on their own user:<handle> channel, private to them from the first load. Pair it with a small who-can-see cue beside the add-a-friend control ("Yours alone — add a friend to share your sill"), while the title, subtitle and hero say what the app is for. This is the shape whenever the app is one person's own collection kept for themselves — the whole space is one private channel, no membership step in the way — and the member doc the add-a-friend control writes grants the friend that one channel (grant: { users: { [doc.userHandle]: [user:${user.userHandle}] } }). The word "personal" routes on what the data is FOR: a personal tracker/journal/notes app is this private per-user shape, while a personal blog or portfolio exists to be READ — that one is the publication shape below (public read + author-owned posts, a roster of one), not a private channel.
When the prompt asks to share, invite, or collaborate — bring in a partner, a buddy, a friend, a team, or a group that co-edits — each shared thing becomes its own object others can be invited into. A seed-swap shelf you invite your sibling to, a board a group co-edits, a document you open to a collaborator: route that shared thing to its own object channel (list:<id>/board:<id>) and self-grant it at creation (grant: { users: { [user.userHandle]: [ch] } }), with the authorHandle create + oldDoc author checks so each item stays on its object. Then let the creator invite a chosen friend in: a share doc the creator authors grants that friend the same channel (grant: { users: { [doc.invitee]: [ch] } }), so they collaborate on that one list — sharing a single list or a whole space is the same grant at a different node of the object graph. A wall, guestbook, or map where each visitor adds their own items is author-owned writes + public read: any signed-in visitor authors their own and everyone reads.
The sharing shape follows what one record is. A record a person keeps for their own life — a check-in, an entry, a line in a list, a plant on a windowsill, a stretch in a routine — belongs to the collection it sits in: the whole collection is private to its person, and the way to share it is adding a friend, who then sees the collection. A piece made for readers — a post, an article, a gallery piece, a portfolio entry — carries its own draft-then-publish step, the ritual readers already know from blogs. A thing made to be seen the moment it exists — a meme, a caption, a guestbook line, a character in a cast — lands on an always-on shared channel. The person's own words move the dial: "let me pick which ones each friend sees" adds choosing items per friend to the add-a-friend control, and "drafts" or "publish" brings the per-item step to any record. Each shape ships with its one who-can-see line beside the control it describes, while the title, subtitle and hero say what the app is for:
- A collection, shared by adding a friend. Everything the person keeps routes to their tree, the channel named by their handle (
user:<handle>), which only they read, and the first screen carries the add-a-friend control — the platform'sHandleInputwriting amemberdoc that grants that handle the collection's channel (grant: { users: { [doc.userHandle]: [ch] } }), the shape the notes example above works through. The friend sees the collection, and the cue beside the control reads in the app's words ("Yours alone — add a friend to share your sill"). This is the resting shape for a tracker, a log, a list, a routine, a shelf, and for an ask kept to themselves ("only I", "just me", "nobody else"). When the person asks to choose what each friend sees, thememberdoc carries the chosen ids and grants that friend each item's own channel — the worked example A friend sees the items picked for them below. - A piece, drafted and then published. Publish at the granularity of a channel: channels are the unit of read isolation, so the piece gets its own channel and only that channel's read-grant flips, from a
visibilityfield the access fn reads — the sketches shape above (const ch = \entry:${doc._id}`; const grant = { users: { [user.userHandle]: [ch] } }; if (doc.visibility === "public") grant.public = [ch];) — for anonymous visitors, or grant a shared app channel for all granted members; the person's other pieces stay on their own channels, each with its own switch. Gate the control onuseVibe(dbName).can`, and reflect the result back in the affordance ("Published — anyone can see this", with an unpublish to flip it back). A publish control that names its destination — "Publish to the wall", "Share to the group" — is the best shape. Making the person's whole space public is fine when that is the intent. - A thing, seen the moment it is made. An open-nature app — an image or meme wall, a bingo card, a party game — puts its records on an always-on
grant.publicchannel from the first rules it ships, builds no publish step, and admits anonymous readers, so everyone with the link sees what people make the moment they make it. The always-on channel is the least-surprise shape for anything playful and low-stakes (an image or meme wall, a group list, a guestbook): agrant.publicchannel, a members' channel, or anaudiencearm.
A privacy-sensitive domain on its own (a journal, a health tracker) gets the two controls from Two controls above, off until the person says so — its buddy picker is this same add-a-friend control. Every app that is neither privacy-sensitive nor an only-me ask ships with a sharing path already in it: either an always-on shared channel its records land on, a publish control, or an add-a-friend control on its first screen, so what a person makes has a way to reach someone from the first build.
Worked example — a cast everyone can meet: shared characters, private chats (a character chat, a creature gallery, a songbook). Sharing is the baseline, so an app where people make things ships a way to see them on its first build, even when the ask names nobody else and the invite screen waits for an option. Here every character lands on one always-on public channel, so every visitor meets the whole cast with no step at all, and each maker edits only their own. A one-tap publish per character (the sketches shape above) is an equally good answer; this example shows the always-on one. Each chat, the thread and the story so far, is private to the person having it. Letting a friend join a chat is an option the app can offer later, as a membership on that chat. The app's code holds no notion of an owner. The canned characters the ask starts with are a fixed set of documents in seed.json, seeded where everyone reads them, and the app writes no copy of them for each visitor. Adding the seed is the owner's choice, so the app works without it: until then the cast opens empty, inviting the first person to make a character. The screen says who can see the cast ("Anyone with the link can meet the cast") beside a copy-link.
access.js
jsexport function characterChat(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
if (doc.type === "character") {
// one cast for the whole app: every character anyone makes, the seeded ones included
return { channels: ["cast"], grant: { public: ["cast"] } };
}
if (doc.type === "message") {
// a chat stays with whoever is having it
const mine = `chat:${user.userHandle}`;
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
}
throw { forbidden: "unknown document type" };
}
// Summary: every character is in one cast anyone who can open the app can meet, and each person edits only the characters they made, while every chat stays private to the person having it. A character and a message carry their writer's handle in authorHandle.
seed.json
json{
"characterChat": [
{ "key": "vega", "_id": "character-vega", "type": "character", "authorHandle": "@owner", "name": "Captain Marisol Vega", "personality": "A bold sky-pirate who fears nothing but dull weather.", "speakingStyle": "Swaggering nautical slang." },
{ "key": "pip", "_id": "character-pip", "type": "character", "authorHandle": "@owner", "name": "Pip", "personality": "A cheerful, curious little robot.", "speakingStyle": "Short excited sentences with beeps." },
{ "key": "vega-hello", "type": "message", "authorHandle": "@owner", "characterId": "character-vega", "role": "character", "text": "Ahoy! The winds are fair, matey." }
]
}
When the rules publish item by item instead, each seeded character carries "visibility": "public", so the seed is a set of published characters rather than a set nobody else can open.
A share control is a link only where the record's channel admits anonymous readers; everywhere else it is the person picker, which takes a handle or an email and shows the invite as pending until they arrive. A link — Copy link, the browser's share sheet, a QR code — is a share only when the person who opens it can read the thing: a record on a grant.public channel, always on or flipped by its visibility field. A record whose channel is reached only by grants is unreadable through a link, so sharing it means letting a person in: the HandleInput picker writes the share doc that grants their handle, exactly as the buddy picker above does, and the same picker takes an email address for someone who is not on the platform yet — it mails them an invite and hands the app an opaque invite:<id> subject to store as the invitee, which binds to their handle when they arrive, so the app never holds the address and ViewerTag shows them as invited until then and by their handle after. The one link that works on a grant-only thing is the one that lands on a request doc: it invites a knock, not a read. An app with a publish control changes the share control's shape with the record's state — the picker before the flip, the link after it.
Worked example — a shared catalog people track against, with per-thing visibility (a music practice ladder, a language drill board, a fitness ladder). The catalog items are public objects anyone proposes; each person's progress is their own; and each person chooses — once per item, never per entry — whether their practice tally is public (on the leaderboard) or buddy-only. The visibility choice lives on a per-(person, item) tracking record that sets the read-grant the entries routed to it inherit.
access.js
jsexport function pieces(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
// A piece: a public object anyone proposes; everyone can read and adopt it.
if (doc.type === "piece") {
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
if (oldDoc && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "creator is fixed" };
return { channels: [`piece:${doc._id}`], grant: { public: [`piece:${doc._id}`] } };
}
// A tracking record: your enrollment in a piece + ONE visibility choice. It sets the
// read-grant on your per-piece channel, so the check-ins routed there inherit it.
if (doc.type === "tracking") {
if (doc.authorHandle !== user.userHandle) throw { forbidden: "your own enrollment" };
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "your own enrollment" };
const ch = `track:${doc.pieceId}:${user.userHandle}`;
const grant = { users: { [user.userHandle]: [ch] } }; // always yourself
if (doc.visibility === "public") grant.public = [ch]; // public -> counts on the leaderboard
else if (doc.buddyHandle) grant.users[doc.buddyHandle] = [ch]; // buddy-only -> you + your buddy
return { channels: [ch], grant };
}
// A check-in: author-owned, routed to your per-piece channel — it inherits that piece's visibility.
if (doc.type === "checkin") {
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not your check-in" };
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not your check-in" };
return { channels: [`track:${doc.pieceId}:${user.userHandle}`] };
}
throw { forbidden: "unknown document type" };
}
// Summary: anyone signed in proposes pieces everyone can see, and each person's practice is their own — on the leaderboard or shared with one buddy, as they choose. Every record carries its writer's handle in authorHandle, and tracking records and check-ins name their piece in pieceId.
The leaderboard is just the access model: read the public track: channels and sum them; each viewer additionally sees their own tallies and any buddy who granted them in. A practice log whose owner asked to keep it to themselves is instead the per-visitor shape shown above: every visitor's pieces and check-ins on their single user:${user.userHandle} channel, self-granted and private to them.
Per-object collaboration is the shape of every second or shared thing (the second worked example above), and certainly of anything that says "invite", "join", "people can join", "collaborate", "share with", "together", "with my partner/team", or names a board/canvas/room/whiteboard a group co-edits — each shared thing is ONE object its members reach directly; it needs no owner. Use the per-object recipe: a channel per object, named from the record (board:${doc._id} on the object, board:${doc.boardId} on its children); the creator self-grants at creation (grant: { users: { [user.userHandle]: [ch] } }); child docs gate on ctx.requireAccess(ch) so any member edits any child in it (not just their own); a member-authored share doc grants a peer the same channel; a request doc — which takes no requireAccess — lets a not-yet-member ask to join. Keep the object's own creator field write-once (if (oldDoc && doc.author !== oldDoc.author) throw) and a child's object-id immutable. Two traps to avoid: don't build it as an open public feed where each person only owns their own items (that abandons the shared membership), and don't gate it behind a single writer (members self-serve via share/request). The app owner is not special in the data model: any signed-in person creates their own object and invites a friend into it, and the rule keys on user.userHandle or the object's creator, never on user.isOwner.
Ownership is just the object graph — whoever authored or created a doc owns it (doc.authorHandle === user.userHandle, checked against oldDoc on updates). There's no broadcaster shape to reach for by default, and owner-only publishing is a dead end — never gate the content itself on requireRole("owner"). A blog, magazine, or publication is public read + author-owned posts, with the owner controlling the author roster: the owner approves authors with a grant doc (if (doc.type === "author") { ctx.requireRole("owner"); return { channels: ["blog:authors"], grant: { users: { [doc.authorHandle]: ["blog:authors"] }, roles: { owner: ["blog:authors"] } } }; } — the one place requireRole("owner") belongs, gating who may author, never the posts); a post then gates on ctx.requireAccess("blog:authors") (membership) and is author-owned (doc.authorHandle === user.userHandle + the oldDoc check), so once approved each author's post is their own object — only they edit it, and they moderate the comments on it (a comment is allowed if it's your comment or you own the post: doc.authorHandle === user.userHandle || doc.postAuthorHandle === user.userHandle). A personal blog is just this with a roster of one. Always gate write UI on useVibe(dbName).can.
The personal blog's post branch is small — public read is the blog's resting state, declared inline on every post result (readers are the point of publishing):
access.js
jsif (doc.type === "post") {
if (!oldDoc) {
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
} else if (doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "author is fixed" };
return { channels: ["posts"], grant: { public: ["posts"] } };
}
When the prompt also asks for drafts, keep the same public resting state for posts and give drafts their own author-only channel (draft:<handle>, granted just to the author) — publishing moves the doc to the public posts channel.
Sharing objects: channels carry objects, roles carry types. A channel is one shareable thing (list:<id>) — membership in it means "can reach this object." A role is a kind of participant (author, editor), a small reusable vocabulary. To let people collaborate on their own objects with no admin in the loop: the creator routes the object to its channel and grants themselves access; any member shares it by granting a peer into the same channel; child docs gate on ctx.requireAccess("list:<id>"). The reserved owner role is the only one auto-seeded — every other role is populated by the app's own grant docs (a member writes a doc whose access-fn output adds another user to a role or channel). So to put someone in a role, write a grant; don't expect a role to be pre-filled. Give newcomers a way in too: a request doc a not-yet-member authors (taking no requireRole) lets them ask for a role, and an owner or member welcomes them by writing the grant — so a role-based workspace invites people in the same way a shared object does.
Reading Resolved Grants (access)
useFireproof() returns an access property — the viewer's resolved roles and channels for that database, computed server-side from the access function's members and grant declarations. Use access.roles (ReadonlySet), access.channels (ReadonlySet), access.hasRole(name), and access.hasChannel(name). Use these to reflect roles/channels in the UI; gate writes with useVibe(dbName).can.
For databases without an access function export, access has empty roles and channels. No separate pending flag — grants arrive alongside the viewer identity, so useViewer().isViewerPending covers both.
App.jsx
jsx<<<<<<< SEARCH
import { useFireproof } from "use-fireproof";
=======
import { useFireproof } from "use-fireproof";
import { useVibe } from "use-vibes";
>>>>>>> REPLACE
App.jsx
jsx<<<<<<< SEARCH
const { useLiveQuery, database } = useFireproof("comments");
=======
const { database, useLiveQuery, access } = useFireproof("comments");
const { can, me } = useVibe("comments");
>>>>>>> REPLACE
App.jsx
jsx<<<<<<< SEARCH
<h3>Recent Documents</h3>
<ul>
{docs.map((doc) => (
<li key={doc._id}>
{doc.text}
<IconButton
size="sm"
variant={doc.favorite ? "solid" : "quiet"}
tone="warn"
aria-pressed={Boolean(doc.favorite)}
label={doc.favorite ? "Unfavorite" : "Favorite"}
icon={<Icon name="star" size="sm" />}
onClick={() => database.put({ ...doc, favorite: !doc.favorite })}
/>
</li>
))}
</ul>
=======
{/* gate writes with useVibe().can, not access.* — and gate the SAME db you write to */}
{can.create({ type: "comment", authorHandle: me?.userHandle }).ok && <CommentForm database={database} />}
{access.hasRole("moderator") && <ModToolsBadge />}
{access.hasChannel("announcements") && <Announcements />}
>>>>>>> REPLACE
The AI agent writes the access function (so it knows the role names) and writes the UI (so it knows which roles gate which components). The access object is the link between them — it lets the UI reflect server-enforced permissions without duplicating the logic.
The access function is the single source of truth for permissions. Gate write surfaces with useVibe(dbName).can, which runs this same access function. The access object (access.hasRole(name), access.hasChannel(name)) reflects the viewer's resolved roles/channels for DISPLAY — role badges, showing/hiding read-only sections — not as the write gate.
access.hasChannel() covers every grant path — public channels, restricted channels, role-expanded channels. The access function decides who gets access and how; the UI uses access.hasChannel(name) to reflect membership for display.
Complete example: Team announcements with channels
This example shows the full round-trip — access.js declares channels and grants; App.jsx reads them back via access. Key details:
- Owner bootstrap: the vibe owner is auto-seeded into the reserved
ownerrole, so gate management operations (channel setup, role grants, moderation) onctx.requireRole("owner")— never on a display flag (the UI gates oncan.*). No bootstrap problem — the seed means the owner can manage without a prior grant. Default content, though, should be author-owned (anyone signed-in creates and edits their own); reserve owner-gating for shared admin surfaces. - Channel identity: Channel docs use
_id: "ch:" + nameso names are unique. The_idis the channel identifier everywhere — inchannels,grant, andctx.requireAccess(). - Channel grant: A channel document grants the creator (
grant.users), addsgrant.publicso all members can read, andgrant.rolesso posters can write. - Write surfaces are gated with
useVibe(dbName).can.create/edit/delete— it runs this same access function, so the UI verdict matches the server. Render.reasonwhen denied. (See use-vibe docs.) ViewerTagtakesuserHandleto render another user (authors, rosters). The current viewer's own pill is system chrome in the Vibes Switch (the logo) — don't add one to the app's UI, except a guarded no-prop{viewer && <ViewerTag />}when you want inline avatar self-edit for any signed-in member (see use-viewer docs). The sign-in ASK is the app's, though:useViewer().requestLogin()from your own button, at the moment the visitor's work becomes worth keeping.
access.js
jsexport function announcements(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
if (doc.type === "channel") {
ctx.requireRole("owner");
return {
channels: [doc._id],
grant: {
users: { [user.userHandle]: [doc._id] },
public: [doc._id],
roles: { poster: [doc._id] },
},
};
}
if (doc.type === "roleGrant") {
ctx.requireRole("owner");
// A grant doc must ALSO route to a channel — a result with no `channels`
// (only `members`/`grant`) is rejected as an "unreadable write". Route it to
// an owner-readable admin channel (not a public one) so the grant persists
// and the owner can read the roster back; the membership then applies.
return {
channels: ["admin:grants"],
members: { [doc.role]: [doc.userHandle] },
grant: { users: { [user.userHandle]: ["admin:grants"] } },
};
}
if (doc.type === "post") {
// Author fixed at create and kept for good; the author edits their own.
if (oldDoc === null) {
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
} else if (doc.authorHandle !== oldDoc.authorHandle) {
throw { forbidden: "cannot change author" };
} else if (oldDoc.authorHandle !== user.userHandle) {
throw { forbidden: "not author" };
}
ctx.requireAccess(doc.channel);
return { channels: [doc.channel] };
}
throw { forbidden: "unknown document type" };
}
// Summary: the owner opens channels everyone can read and names who posts; posters write in their channels and edit their own posts. A post carries its writer's handle in authorHandle.
App.jsx — useVibe().can gates every write surface (posts AND owner-only management); access.hasChannel() reflects display-only membership:
App.jsx
jsximport React from "react";
import { useFireproof } from "use-fireproof";
import { useViewer, useVibe } from "use-vibes";
import { Button, Field, RowCard, Skeleton, Stack, Text, Textarea } from "@vibes.diy/pouf";
export default function App() {
const { viewer, isViewerPending, ViewerTag } = useViewer();
const { database, useLiveQuery, access } = useFireproof("announcements");
const { can, ready } = useVibe("announcements");
const { docs: posts } = useLiveQuery("type", { key: "post" });
const [draft, setDraft] = React.useState("");
const [channel, setChannel] = React.useState("general");
// Build each candidate from the doc you'll actually write — the access function
// checks authorHandle/channel (and owner-only for roleGrant), so a bare partial
// would be denied and hide the control even from users who can act.
const canPost = can.create({ type: "post", channel, authorHandle: viewer?.userHandle });
// Owner-only management gates on can.* too — the access fn calls
// ctx.requireRole("owner"), so this verdict is false for everyone but the owner.
const canGrant = can.create({ type: "roleGrant", role: "poster", userHandle: "newUser" });
// Hold the page while identity and the access function resolve: every verdict
// reads pending until then, so the write surface waits on a quiet checking state.
if (isViewerPending || !ready) return <Skeleton />;
async function submitPost() {
if (!draft.trim() || !viewer) return;
setDraft("");
const { ok } = await database.put({
type: "post",
channel,
body: draft.trim(),
authorHandle: viewer.userHandle,
createdAt: Date.now(),
});
if (!ok) setDraft((now) => now || draft);
}
return (
<Stack gap={4}>
{/* No current-user pill / sign-in button here — that's system chrome in the
Vibes Switch (the panel the logo opens). ViewerTag below renders post authors. */}
{/* gate the write surface on useVibe().can — it runs the access function */}
{canPost.ok ? (
<form
onSubmit={(e) => {
e.preventDefault();
submitPost();
}}
>
<Stack gap={3}>
<Field label="Announcement">{(id, describedBy) => <Textarea id={id} describedBy={describedBy} value={draft} onChange={setDraft} />}</Field>
<Button type="submit">Post</Button>
</Stack>
</form>
) : (
viewer && <Text muted>Posting opens up once the owner adds you as a poster.</Text>
)}
{/* owner-only management — gated on can.* */}
{canGrant.ok && (
<Button type="button" variant="quiet" onClick={() => database.put({ type: "roleGrant", role: "poster", userHandle: "newUser" })}>
Grant poster role
</Button>
)}
{posts.map((p) => (
<RowCard key={p._id}>
<ViewerTag userHandle={p.authorHandle} />
<Text>{p.body}</Text>
{can.delete(p).ok && (
<Button type="button" variant="quiet" tone="down" size="sm" onClick={() => database.del(p._id)}>
Delete
</Button>
)}
</RowCard>
))}
</Stack>
);
}
The pattern: useVibe().can gates every write surface — including owner-only management, which the access function enforces via ctx.requireRole("owner") so can.create({ type: "roleGrant", … }) is false for non-owners. access.hasChannel() reflects display-only membership; the write gate is always can.*. The access function is the server-side authority — useVibe().can is how the UI reflects its decisions for writes.
Owner-management panels (appoint/revoke moderators, grant/revoke roles) gate on can.*. Gate each mutating control — and the panel's visibility — on the can.* verdict from the doc you'll actually write: appoint on can.create({ type: "modGrant", role: "moderator", userHandle }).ok, revoke on can.delete(grantDoc).ok — and render .reason when denied. can.* runs the app's own access.js to produce the verdict, so the control's enabled state and message track what the access function decides and stay correct as the rule grows beyond owner-only (a delegated admin role, say).
jsx// Gate visibility AND each write on the can.* verdict, and supply the denial reason
const canAppoint = can.create({ type: "modGrant", role: "moderator", userHandle });
const canRevoke = can.delete(grantDoc);
{(canAppoint.ok || canRevoke.ok) && (
<ModeratorPanel>
{canAppoint.ok && (
<Button type="button" onClick={() => database.put({ type: "modGrant", role: "moderator", userHandle })}>Appoint</Button>
)}
{canRevoke.ok ? (
<Button type="button" variant="quiet" tone="down" onClick={() => database.del(grantDoc._id)}>Revoke</Button>
) : (
<Text muted>{canRevoke.reason}</Text>
)}
</ModeratorPanel>
)}
Example: Channel board with open channels (any member posts)
Channels everyone can read, and any signed-in user can post to. The channel doc is grant.public (read for everyone) and the post rule checks only the author — no ctx.requireAccess, because public is read-only and would block every non-owner. (For a members-only board where the owner appoints who can post, grant a poster role and requireAccess it, as in the announcements example above.) The UI uses access.hasChannel() to filter which channels to display, and useVibe().can to gate writes.
access.js
jsexport function chat(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
if (doc.type === "channel") {
ctx.requireRole("owner");
// Open channel: public READ for everyone. No write-membership grant is
// needed — any signed-in user may post (see the post rule below).
return { channels: [doc._id], grant: { public: [doc._id] } };
}
if (doc.type === "post") {
// Author fixed at create and kept for good; the author edits their own.
if (oldDoc === null) {
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
} else if (doc.authorHandle !== oldDoc.authorHandle) {
throw { forbidden: "cannot change author" };
} else if (oldDoc.authorHandle !== user.userHandle) {
throw { forbidden: "not author" };
}
// Any signed-in author may post to this open channel. Do NOT call
// ctx.requireAccess(doc.channel) here: the channel is grant.public
// (read-only), which never satisfies requireAccess, so gating on it would
// block every non-owner from posting. requireAccess is for members-only
// channels whose writers were granted membership (see announcements above).
return { channels: [doc.channel] };
}
throw { forbidden: "unknown document type" };
}
// Summary: the owner opens channels everyone can read, and anyone signed in posts in them and edits their own posts. A post carries its writer's handle in authorHandle.
App.jsx — access.hasChannel() filters which channels are visible (display); useVibe().can gates the write surface:
jsx<<<<<<< SEARCH
const { database, useLiveQuery, access } = useFireproof("announcements");
=======
const { database, useLiveQuery, access } = useFireproof("chat");
const { docs: channels } = useLiveQuery("type", { key: "channel" });
// Build the candidate from the doc you'll write — the access fn checks
// authorHandle, so a bare { type: "post" } would be denied and hide the form.
const canPost = useVibe("chat").can.create({ type: "post", channel, authorHandle: viewer?.userHandle });
>>>>>>> REPLACE
jsx<<<<<<< SEARCH
{viewer && access.hasChannel(channel) && (
=======
{/* filter to channels the viewer can see — use _id as channel identifier (display only) */}
{channels.filter((ch) => access.hasChannel(ch._id)).map((ch) => (
<Button key={ch._id} type="button" size="sm" variant={ch._id === channel ? "solid" : "quiet"} tone="info" onClick={() => setChannel(ch._id)}>{ch.name}</Button>
))}
{/* gate the write surface on useVibe().can */}
{canPost.ok ? (
>>>>>>> REPLACE
Channel _id is the channel identifier everywhere. The access function uses doc._id for routing and grants. A deterministic _id like "ch:" + name enforces uniqueness — two users can't create duplicate channels.
Example: Per-object sharing (collaborate on your own objects, no admin)
Reach for this for a second or shared thing, and certainly whenever the prompt says invite, join, collaborate, share with, together, with my partner/team — a shared seed-swap shelf you invite a sibling to, a whiteboard people can join, a quilting circle's pattern queue a group builds together, a tracker one person starts and later shares with a coach. A list app where every signed-in user makes their own lists, sees only their own, and can invite anyone to collaborate on a specific list — peer to peer, with no app admin in the loop. The pattern: a channel per object (list:<id>); the creator grants themselves that channel at creation; child docs (items) gate on ctx.requireAccess of the list's channel, so any member edits any item; any current member shares the list by granting another user the same channel. Membership is direct grant.users, so each viewer's access scales with their own memberships. Nothing a person writes reaches anyone else until they chose it: a per-object channel starts with its creator as the only reader, and every other reader arrives through a grant that creator wrote.
access.js
jsexport default function (doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
const ch = (id) => `list:${id}`;
if (doc.type === "list") {
// Creator owns the list doc; route it to its own channel and grant self.
const author = oldDoc ? oldDoc.author : doc.author;
if (author !== user.userHandle) throw { forbidden: "not your list" };
// author is write-once: an update must not re-author the list (which would
// change who can edit it and hand control to someone never granted).
if (oldDoc && doc.author !== oldDoc.author) throw { forbidden: "cannot change author" };
return { channels: [ch(doc._id)], grant: { users: { [user.userHandle]: [ch(doc._id)] } } };
}
if (doc.type === "item") {
// Any member of the list may add/edit items in it. listId is immutable —
// without this, a member of list X could re-point an existing item from a
// list they don't belong to into X (it would still pass requireAccess(X)).
if (oldDoc && oldDoc.listId !== doc.listId) throw { forbidden: "cannot move item" };
ctx.requireAccess(ch(doc.listId));
return { channels: [ch(doc.listId)] };
}
if (doc.type === "share") {
// Any current member invites a peer by handle — grants them the list channel.
// Route the share doc to the list channel itself (every member already holds
// it), so members see who was added without a second channel to grant.
// listId is immutable too, so only a member of the list a share is already on
// changes it — a member of list X never rewrites list Y's share as one on X.
if (oldDoc && oldDoc.listId !== doc.listId) throw { forbidden: "a share stays on its list" };
ctx.requireAccess(ch(doc.listId));
return { channels: [ch(doc.listId)], grant: { users: { [doc.invitee]: [ch(doc.listId)] } } };
}
throw { forbidden: "unknown document type" };
}
// Summary: each person makes their own lists and shares one with a friend, and everyone on a list adds and edits its items. A list carries its writer's handle in author, and items and shares name their list in listId.
To invite someone who isn't a member yet without knowing their handle in advance, invert the flow with a request doc: a request type takes no ctx.requireAccess (any signed-in user may create one — their handle is user.userHandle, unforgeable) and routes to the list channel ch(doc.listId), where current members read it (the requester can't read their own request back — they aren't a member yet — which is fine; they just wait to be granted). A member then writes the share above to approve.
App.jsx — access.hasChannel() shows only the lists the viewer belongs to; useVibe("lists").can gates each write surface (create list, add item, invite peer):
jsx<<<<<<< SEARCH
const { database, useLiveQuery, access } = useFireproof("notes");
=======
const { database, useLiveQuery, access } = useFireproof("lists");
const { me, can } = useVibe("lists");
const { docs: lists } = useLiveQuery("type", { key: "list" });
const visible = lists.filter((l) => access.hasChannel(`list:${l._id}`));
// Each member edits any item — items gate on requireAccess(list:<id>), not authorHandle.
const canAddItem = (list) => can.create({ type: "item", listId: list._id, authorHandle: me?.userHandle }).ok;
const canShare = (list) => can.create({ type: "share", listId: list._id, invitee: "x" }).ok;
>>>>>>> REPLACE
jsx<<<<<<< SEARCH
{viewer && <Button type="button" onClick={addNote}>Add a note</Button>}
=======
{/* create list — any signed-in visitor can start their own */}
{can.create({ type: "list", author: me?.userHandle }).ok && (
<Button type="button" onClick={() => database.put({ type: "list", author: me.userHandle, name: "new list" })}>
New list
</Button>
)}
{visible.map((list) => (
<section key={list._id}>
<Heading level={2}>{list.name}</Heading>
{/* add-item form is shown only when the access fn would accept the write */}
{canAddItem(list) && <AddItemForm listId={list._id} />}
{/* invite peer — only shown to members of this list */}
{canShare(list) && <ShareForm listId={list._id} />}
</section>
))}
>>>>>>> REPLACE
A second or shared object, and a member doc that names the friend
Reach for this when the person makes a second thing of the same kind, or a thing several people share — a second board beside their own records, a seed-swap shelf a sibling is invited into, a beekeeping club's hive log, a trip a group plans together. The person's own records still open on their tree, the channel named by their handle (the notes example above); the object is the next node of the graph. Four parts make it feel finished:
- The object is a stored document written by the person's own action — a submit that names it ("Start a board"), a tap on a template, a pick from a list. It has its own branch in
access.js, carriescreatorHandle, and its_idnames the channel its records route to (board:<_id>). The creator holds that channel and its admin channel from the object's own write, so they remain a member when the board is empty, when a collaborator writes first, and after the last creator-authored item is deleted. Nothing creates it on load or on mount. The child doc stores that board id rather than having each writer derive one: the create uses the displayed object's id and the rule holds it fixed from then on, so a card stays on the board its creator put it on however many people edit it later. - Membership is a
memberdoc, and picking the friend isHandleInput(useViewer(), see use-viewer.md — the platform's people-picker, so the handle on the doc is one the platform resolved). The doc grants the named person the object's channel:grant: { users: { [doc.userHandle]: [chan] } }. - The object's creator is its admin, held on a second channel (
`${chan}/admin`) that the creator self-grants at creation. Adding a member is gated on that admin channel, so members collaborate and the creator decides who joins. The app owner holds no place in any of this: every rule readsuser.userHandleor the object's owncreatorHandle. - Recipients can select every readable object. The ordinary object query already applies the access rules, including grants from independent membership records. Its complete result supplies the picker. Prefer the person's own object as the initial selection once it holds something, keeping invited objects selectable alongside it. A person with nothing of their own yet (the friend who just followed an invite) opens on the object shared with them, so their first screen is the trip, board or list they were invited to rather than an empty one of their own. This matters most when each person's own space exists without being created (a list per handle): an empty implicit space never outranks a shared object. Resolve the selected id against the current readable results on each render, so a revoked object leaves both the picker and the content view. Use the selected object's id for child queries, new content, and invitations;
cancontinues to gate each write.
access.js
jsexport function boards(doc, oldDoc, user, ctx) {
if (!user?.userHandle) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
const safeId = (id) => {
if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) throw { forbidden: "bad id" };
return id;
};
const ch = (id) => "board:" + safeId(id);
if (doc.type === "board") {
// The person's own submit makes a board; its creator holds it and its admin channel.
if (oldDoc) {
if (doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "creator is fixed" };
ctx.requireAccess(ch(doc._id) + "/admin"); // renaming is an admin act — updates only
} else if (doc.creatorHandle !== user.userHandle) {
throw { forbidden: "create your own board" };
}
return { channels: [ch(doc._id)], grant: { users: { [doc.creatorHandle]: [ch(doc._id), ch(doc._id) + "/admin"] } } };
}
if (doc.type === "card") {
// The card STORES its board id from the moment it is created and holds it fixed after —
// including the absent-to-present move, so a later editor always finds the id already on the doc.
if (!oldDoc && (typeof doc.boardId !== "string" || doc.boardId.length === 0)) {
throw { forbidden: "a card names its board" };
}
if (oldDoc && doc.boardId !== oldDoc.boardId) throw { forbidden: "card stays on its board" };
const chan = ch(doc.boardId);
ctx.requireAccess(chan); // any member edits any card on it; the creator holds it from the board record
return { channels: [chan] };
}
if (doc.type === "member") {
// The board's admin invites a person the picker resolved; the doc IS the grant.
// Fixed once written — an identical re-put (a second device replaying the same save) is
// harmless and admitted; any change to who, where or by whom is refused.
if (oldDoc && (doc.userHandle !== oldDoc.userHandle || doc.boardId !== oldDoc.boardId || doc.addedBy !== oldDoc.addedBy))
throw { forbidden: "membership grants are fixed" };
if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
ctx.requireAccess(ch(doc.boardId) + "/admin");
return { channels: [ch(doc.boardId)], grant: { users: { [doc.userHandle]: [ch(doc.boardId)] } } };
}
throw { forbidden: "unknown document type" };
}
// Summary: anyone signed in starts boards of their own, a board's creator lets a friend in with a member record, and members add and edit every card on it. A board carries its creator's handle in creatorHandle, a card names its board in boardId, and a member record names the friend in userHandle and its writer in addedBy.
App.jsx — the invite surface is HandleInput plus one member write, shown to whoever the access fn would accept:
jsxconst { HandleInput } = useViewer();
const { database } = useFireproof("boards");
const { me, can } = useVibe("boards");
// One fixed id per (board, handle): the same person added twice is the same record.
const memberFor = (boardId, handle) => ({ _id: `member:${boardId}:${handle}`, type: "member", boardId, userHandle: handle, addedBy: me?.userHandle });
const addMember = async (boardId, handle) => {
const member = memberFor(boardId, handle);
if (!handle || !can.create(member).ok) return;
await database.put(member); // the exact value the control checked
};
// Pass the displayed board's id; the card keeps it for good.
const addCard = async (text, boardId) => {
const card = { type: "card", boardId, text };
if (!can.create(card).ok) return;
return database.put(card);
};
// …
{can.create(memberFor(board._id, "friend")).ok && <HandleInput onChange={(h) => h && addMember(board._id, h)} placeholder="Add a friend…" />}
An email typed into that HandleInput reaches addMember as an opaque invite:<id> subject, stored as userHandle like any handle, and ViewerTag shows it as invited until they arrive.
App.jsx — the same board list serves the creator and the invited recipient. BoardContents uses board._id for its content queries, calls addCard(text, board._id) and addMember(board._id, handle), and gates its write controls with can. The creation form stays reachable with an empty readable list, and it selects the person's new board after saving it.
App.jsx
jsxfunction BoardWorkspace() {
const { database, useLiveQuery } = useFireproof("boards");
const { me, can, ready } = useVibe("boards");
const [pickedBoardId, setPickedBoardId] = React.useState();
const [name, setName] = React.useState("");
// The new board's id is made once per draft, so the gate and the put check one complete record.
const [draftId, setDraftId] = React.useState(() => "board-" + crypto.randomUUID());
const { docs: boards } = useLiveQuery("type", { key: "board" });
// The person's own submit starts a board: the form checks and saves this exact record.
const newBoard = { _id: draftId, type: "board", name: name.trim(), creatorHandle: me?.userHandle };
const startBoard = async (event) => {
event.preventDefault();
if (!ready || !newBoard.name || !can.create(newBoard).ok) return;
setName(""); // ready for the next name at once
setDraftId("board-" + crypto.randomUUID()); // and the next board gets an id of its own
const { ok } = await database.put(newBoard);
if (ok) setPickedBoardId(newBoard._id);
else setName((now) => now || newBoard.name);
};
const board =
boards.find((board) => board._id === pickedBoardId) ??
boards.find((board) => board.creatorHandle === me?.userHandle) ??
boards[0];
return (
<section>
{boards.length > 0 && (
<Field label="Board">
{(id, describedBy) => (
<Select
id={id}
describedBy={describedBy}
value={board?._id ?? ""}
onChange={setPickedBoardId}
options={boards.map((board) => ({ value: board._id, label: board.name ?? board._id }))}
/>
)}
</Field>
)}
{board && <BoardContents board={board} />}
<form onSubmit={startBoard}>
<Field label="New board">
{(id, describedBy) => <Input id={id} describedBy={describedBy} value={name} onChange={setName} />}
</Field>
<Button type="submit" disabled={!ready || !newBoard.name || !can.create(newBoard).ok}>Start a board</Button>
</form>
</section>
);
}
A friend sees the items picked for them
Reach for this only when the person asks to choose what each friend sees — "when I add a friend, let me pick which plants they can see". Each plant lands on the person's tree and on its own channel, named by its keeper and its id, and the member doc carries the chosen ids and grants that friend each chosen item's channel: the friend sees those items and nothing else on the sill, and saving the member doc again with a different list changes what they see. Because an item's channel carries its keeper's handle, a member doc can only ever grant its writer's own items. The add-a-friend control is the same HandleInput, with the person's items as checkboxes beside it, and the cue beside it says what the friend will see ("Pick the plants this friend sees").
access.js
jsexport function sill(doc, oldDoc, user, ctx) {
if (!user?.userHandle) throw { forbidden: "sign in" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
const safeId = (id) => {
if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) throw { forbidden: "bad id" };
return id;
};
const mine = `user:${user.userHandle}`; // the person's own sill: the channel named by their handle
const item = (id) => `plant:${user.userHandle}:${safeId(id)}`; // only ever this writer's own plants
if (doc.type === "plant") {
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "a plant stays its writer's" };
return { channels: [mine, item(doc._id)], grant: { users: { [user.userHandle]: [mine, item(doc._id)] } } };
}
if (doc.type === "member") {
// The sill's keeper writes a membership; who it names and who wrote it stay fixed,
// and the picked plants are the one thing a later save changes.
if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
if (oldDoc && (doc.userHandle !== oldDoc.userHandle || doc.addedBy !== oldDoc.addedBy)) throw { forbidden: "who is fixed" };
const picked = (doc.plantIds || []).map(item);
// A first pick names at least one plant; a re-pick may clear them all, and the friend then sees none.
if (!oldDoc && picked.length === 0) throw { forbidden: "pick a plant to share" };
const grant = { users: { [user.userHandle]: [mine] } };
if (picked.length > 0) grant.users[doc.userHandle] = picked;
return { channels: [mine], grant };
}
throw { forbidden: "unknown document type" };
}
// Summary: each person's plants are theirs alone, and a friend they add sees exactly the plants picked for them. A plant carries its writer's handle in authorHandle; a member record names the friend in userHandle, its writer in addedBy and the plants they see in plantIds.
App.jsx
jsxconst { HandleInput } = useViewer();
const { database, useLiveQuery } = useFireproof("sill");
const { me, can } = useVibe("sill");
const [picked, setPicked] = React.useState([]);
const addFriend = async (handle) => {
const member = { _id: `member:${me.userHandle}:${handle}`, type: "member", userHandle: handle, plantIds: picked, addedBy: me.userHandle };
if (can.create(member).ok) await database.put(member);
};
// Changing what a friend sees is the same record, saved again with the new list.
const repick = async (member, plantIds) => {
const next = { ...member, plantIds };
if (can.edit(next).ok) await database.put(next);
};
// …
{plants.map((p) => (
<Checkbox
key={p._id}
label={p.name}
checked={picked.includes(p._id)}
onChange={(checked) => setPicked(checked === true ? [...picked, p._id] : picked.filter((id) => id !== p._id))}
/>
))}
<HandleInput onChange={(h) => h && addFriend(h)} placeholder="Add a friend who sees these…" />
The list holds what the person made and what they were let into
The app's list of objects reads them by their type, so it holds the ones the signed-in person made and the ones a membership lets them into, read through the same rules. The member record grants its board's channel, so the friend's own useLiveQuery("type", { key: "board" }) returns that board beside their own the moment the membership lands; creatorHandle then says who made each one. A friend let into someone's tree finds it the same way: read member records by type (useLiveQuery("type", { key: "member" })), and each readable one in someone else's tree names a tree they can open — dedupe by that tree's handle. Match on the type, never on the friend's own handle: a friend invited by email is stored as invite:<id>, and the grant reaches their handle once they arrive without rewriting the record. A shared board carries ViewerTag for the person who made it, in the same list or in a section that renders once there is at least one, so a person keeping the app to themselves sees one plain list. Opening a shared board is the same BoardContents: its cards are read by its id and written with that id, the rules route them to its channel, and can gates each write.
jsxconst { ViewerTag } = useViewer();
const { docs: boards } = useLiveQuery("type", { key: "board" });
const sharedWithMe = boards.filter((board) => board.creatorHandle !== me?.userHandle);
// …
{sharedWithMe.length > 0 && (
<section>
<Heading level={2}>Shared with you</Heading>
{sharedWithMe.map((board) => (
<RowCard key={board._id} onClick={() => setPickedBoardId(board._id)}>
<Text>{board.name ?? board._id}</Text> <ViewerTag userHandle={board.creatorHandle} />
</RowCard>
))}
</section>
)}
A new access function has no use for user.isOwner at all: a rule about a person keys on user.userHandle or the object's own creator field, a rule about app-wide authority keys on a role through ctx.requireRole, and a rule about the app's own automation keys on user.backend.
App-wide authority is a role the owner hands out
Some authority belongs to the whole app rather than to one person's records — who edits the menu, who posts announcements, who reviews submissions. That authority is a role. The owner holds the reserved owner role from the start, with no record needed; every other role is filled by a roleGrant record the owner writes, gated on ctx.requireRole("owner"), whose output names the member: members: { [doc.role]: [doc.userHandle] }. The seed carries the owner's own grant ({ "type": "roleGrant", "role": "menu-editor", "userHandle": "@owner" }, above the records it unlocks), so the demo opens with the owner able to edit, and the same control hands the role to a friend later. The records the role governs check ctx.requireRole("menu-editor"); the owner and the first visitor arrive the same way, and the rules never ask who the owner is.
Worked example — a cookie shop's order form: the owner adds flavors and friends place orders. The menu is app-wide, so a flavor needs the menu-editor role and reads for everyone. An order is a customer's own record, so it lives in their tree, and a grant.roles entry lets every menu editor read the orders placed with the shop.
access.js
jsexport function shop(doc, oldDoc, user, ctx) {
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
if (doc.type === "flavor") {
// The menu is app-wide: a menu editor adds and changes flavors, and everyone reads them.
ctx.requireRole("menu-editor");
return { channels: ["menu"], grant: { public: ["menu"] } };
}
if (!user?.userHandle) throw { forbidden: "sign in" };
const me = user.userHandle;
if (doc.type === "roleGrant") {
// App-wide authority: the owner holds the owner role from the start and hands out the rest.
ctx.requireRole("owner");
if (doc.role !== "menu-editor") throw { forbidden: "unknown role" };
return { channels: ["admin:roles"], members: { [doc.role]: [doc.userHandle] }, grant: { roles: { owner: ["admin:roles"] } } };
}
if (doc.type === "order") {
// An order is its customer's own record, in their tree; every menu editor reads it.
if (doc.customerHandle !== me) throw { forbidden: "order for yourself" };
if (oldDoc && oldDoc.customerHandle !== me) throw { forbidden: "an order stays its customer's" };
const tree = "user:" + me;
return { channels: [tree], grant: { users: { [me]: [tree] }, roles: { "menu-editor": [tree] } } };
}
throw { forbidden: "unknown document type" };
}
// Summary: everyone reads the menu and only menu editors change it, the owner names the menu editors with a role grant, and each customer's orders are their own, read by the menu editors. An order carries its customer's handle in customerHandle; a role grant names its role in role and its member in userHandle.
seed.json
json{
"shop": [
{ "key": "owner-edits-menu", "type": "roleGrant", "role": "menu-editor", "userHandle": "@owner" },
{ "key": "snickerdoodle", "type": "flavor", "name": "Snickerdoodle", "price": 3 },
{ "key": "brown-butter", "type": "flavor", "name": "Brown butter chocolate chip", "price": 4 }
]
}
App.jsx — every control checks can.create on the exact record it saves: the flavor form, each flavor's order button, and the add-a-menu-editor picker, which only the owner role passes.
App.jsx
jsxfunction Shop() {
const { database, useLiveQuery } = useFireproof("shop");
const { me, can, ready } = useVibe("shop");
const { HandleInput, ViewerTag, requestLogin } = useViewer();
const [name, setName] = React.useState("");
const { docs: flavors } = useLiveQuery("type", { key: "flavor" });
// A customer reads their own orders; a menu editor reads every customer's.
const { docs: orders } = useLiveQuery("type", { key: "order" });
const flavor = { type: "flavor", name: name.trim() };
const orderFor = (f) => ({ type: "order", flavorId: f._id, flavorName: f.name, customerHandle: me?.userHandle });
const editorFor = (handle) => ({ _id: "role-menu-editor-" + handle, type: "roleGrant", role: "menu-editor", userHandle: handle });
const addFlavor = async (event) => {
event.preventDefault();
if (!ready || !flavor.name || !can.create(flavor).ok) return;
setName(""); // ready for the next flavor at once
const { ok } = await database.put(flavor); // the exact value the control checked
if (!ok) setName((now) => now || flavor.name);
};
const order = async (f) => {
const next = orderFor(f);
if (!ready || !can.create(next).ok) return;
await database.put(next);
};
const addEditor = async (handle) => {
const grant = editorFor(handle);
if (!ready || !handle || !can.create(grant).ok) return;
await database.put(grant);
};
if (!ready) return <p role="status">Opening the shop…</p>;
return (
<Stack gap={3}>
{can.create(flavor).ok && (
<form onSubmit={addFlavor}>
<Field label="New flavor">
{(id, describedBy) => <Input id={id} describedBy={describedBy} value={name} onChange={setName} />}
</Field>
<Button type="submit" disabled={!flavor.name}>Add flavor</Button>
</form>
)}
{flavors.map((f) => (
<RowCard key={f._id}>
<Text>{f.name}</Text>
{can.create(orderFor(f)).ok && <Button type="button" size="sm" onClick={() => order(f)}>Order</Button>}
</RowCard>
))}
{!me && <Button type="button" variant="quiet" onClick={() => requestLogin()}>Sign in to place an order</Button>}
{orders.map((o) => (
<RowCard key={o._id}>
<Text>{o.flavorName}</Text>
{o.customerHandle !== me?.userHandle && <ViewerTag userHandle={o.customerHandle} />}
</RowCard>
))}
{can.create(editorFor("friend")).ok && (
<HandleInput onChange={(h) => h && addEditor(h)} placeholder="Add a menu editor…" />
)}
</Stack>
);
}
A person the ask names is let in by a record
When the instruction names somebody by their handle — "let my friend priya-k see and add to my seed-swap shelf", "an expedition notebook for me and theo" — the build's part is the rule that admits a membership and the screen that writes one; the membership itself is a record. It is the same member doc the rule admits and the invite surface above writes, saved for that handle in the asker's own tree — or on the second object the ask names — once the rules are live, by whoever is driving the build acting as the maker, read back, and only then spoken of as in. An app whose first build carried the rules and no invite screen still states this shape in full, in access.js's member branch: the type and fields that branch admits are the record a later save writes, and an edit that adds the screen afterwards writes that same record.
So the record's shape is a contract the files state plainly. The _id is fixed by the place and the handle — `member:${me.userHandle}:${userHandle}` in the person's tree, `member:${boardId}:${userHandle}` on a board — so a second attempt is the same record rather than a twin, and a read by that id answers "is this person already in?" on its own. The fields are the ones the rule reads: type, userHandle, addedBy set to the writer's own handle, and the object's id when the membership is on a second object. The notes rules above route every record to the writer's own channel and read no tree field, so the record below carries none. When an app's rules instead make every record name its tree in a field before any type branch — studioHandle, refused with "a record names its tree" when absent — the member record carries the same studioHandle as the tree it joins, in the can.create preview that decides whether the add-a-friend control shows and in the record the invite saves; build both with one memberFor(handle) so they cannot drift, and a preview that leaves the field out is refused and hides the control for everyone. The member rule admits an identical re-put, so a replay lands quietly. The tree's channel stays its person's because their own writes grant it, and a second object keeps its creator in the collaboration through its own record, independently of every content and invitation record. One invite handler in App.jsx writes exactly that shape, which is what makes the app's own screen and any later save agree on one record.
jsxconst { HandleInput } = useViewer();
const { database } = useFireproof("notes");
const { me, can } = useVibe("notes");
const addFriend = async (handle) => {
// One fixed id per (tree, handle): the same person added twice is the same record.
const member = { _id: `member:${me.userHandle}:${handle}`, type: "member", userHandle: handle, addedBy: me.userHandle };
if (can.create(member).ok) await database.put(member);
};
// …
<HandleInput onChange={(h) => h && addFriend(h)} placeholder="Add a friend…" />;
A handle is the thing the person typed as one — a platform handle, the same token HandleInput would resolve. A plain first name ("share it with Priya") or an email address is a person the maker adds through the invite screen, which takes a handle or an email: the build has no way to know which account that name means, so the invite screen ships with this build and the narration says the maker can add that person there.
That bounds what the build's own closing line says: the rules and the screen it just wrote are what the files carry, so the honest sentence is "your seed-swap shelf now admits a membership for priya-k, and the invite screen writes one". "priya-k is in" belongs to the save that follows — the read for an existing membership, the write in this shape, and the read that finds it afterwards.
More worked round-trip examples
Example: Workspace chat with channels
access.js
jsexport function chat(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "authentication required" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" }; // a delete arrives as the stored record, type and all
if (doc.type === "channel-meta") {
if (doc.ownerHandle !== user.userHandle) throw { forbidden: "not owner" };
if (oldDoc && oldDoc.ownerHandle !== user.userHandle) throw { forbidden: "not owner" };
return {
channels: [doc._id],
grant: {
users: Object.fromEntries([[doc.ownerHandle, [doc._id]], ...doc.memberHandles.map((h) => [h, [doc._id]])]),
},
};
}
if (doc.type === "message") {
// Author fixed at create and kept for good; the author edits their own.
if (oldDoc === null) {
if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
} else if (doc.userHandle !== oldDoc.userHandle) {
throw { forbidden: "cannot change author" };
} else if (oldDoc.userHandle !== user.userHandle) {
throw { forbidden: "not author" };
}
ctx.requireAccess(doc.channelId);
return { channels: [doc.channelId] };
}
if (doc.type === "channel-invite") {
if (doc.senderHandle !== user.userHandle) throw { forbidden: "not sender" };
if (oldDoc && oldDoc.senderHandle !== user.userHandle) throw { forbidden: "not sender" };
ctx.requireAccess(doc.channelId);
return {
channels: [doc.channelId],
grant: { users: { [doc.inviteeHandle]: [doc.channelId] } },
};
}
throw { forbidden: "unknown document type" };
}
// Summary: a channel's owner names its members, and members read and post messages and invite others in. A channel carries its owner in ownerHandle, a message its writer in userHandle, and an invite its sender in senderHandle.
This single access function handles three document types: channel-meta — owner creates a channel and grants access to listed members, message — only the author can post, must already have channel access, channel-invite — any channel member can invite others; deleting the invite revokes the grant.
App.jsx — show only channels the viewer is in (access.hasChannel), gate the compose box and invite form on useVibe().can:
jsx<<<<<<< SEARCH
const { database, useLiveQuery, access } = useFireproof("notes");
=======
const { database, useLiveQuery, access } = useFireproof("chat");
const { me, can } = useVibe("chat");
const { docs: channels } = useLiveQuery("type", { key: "channel-meta" });
// Filter to channels the viewer has been granted into — non-members never see them in the list.
const myChannels = channels.filter((ch) => access.hasChannel(ch._id));
const [channelId, setChannelId] = React.useState(null);
const canPost = can.create({ type: "message", channelId, userHandle: me?.userHandle });
const canInvite = can.create({ type: "channel-invite", channelId, senderHandle: me?.userHandle, inviteeHandle: "x" });
>>>>>>> REPLACE
jsx<<<<<<< SEARCH
{viewer && <Compose onSend={send} />}
=======
{/* sidebar lists only the channels the viewer is in */}
<nav>{myChannels.map((ch) => <Button key={ch._id} type="button" size="sm" variant={ch._id === channelId ? "solid" : "quiet"} tone="info" onClick={() => setChannelId(ch._id)}>{ch.name}</Button>)}</nav>
{/* compose is shown only when the viewer is a member of the selected channel */}
{channelId && canPost.ok ? (
<Compose onSend={(text) => database.put({ type: "message", channelId, userHandle: me.userHandle, text })} />
) : (
<Text muted>{canPost.reason || "Pick a channel"}</Text>
)}
{/* any member of this channel may invite a peer */}
{channelId && canInvite.ok && <InviteForm channelId={channelId} senderHandle={me.userHandle} />}
>>>>>>> REPLACE
Example: Anonymous survey with role-gated results
access.js
jsexport function survey(doc, oldDoc, user, ctx) {
// A record keeps its type, so a response stays a private response and results update only as results.
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
if (doc.type === "survey-response") {
if (oldDoc) throw { forbidden: "responses are write-once" };
return { channels: ["inbound-responses"], allowAnonymous: true };
}
if (doc.type === "survey-config") {
ctx.requireRole("owner");
// Route this grant/config doc to an owner-readable admin channel — a
// grant-only result (no `channels`) is rejected as an "unreadable write".
return {
channels: ["admin:grants"],
grant: {
users: { [user.userHandle]: ["admin:grants"] },
roles: { "feedback-team": ["inbound-responses"] },
},
};
}
if (doc.type === "team-member") {
// The owner names each person on the feedback team; the record IS the membership.
ctx.requireRole("owner");
if (typeof doc.memberHandle !== "string" || doc.memberHandle.length === 0) throw { forbidden: "name a team member" };
return {
channels: ["admin:grants"],
members: { "feedback-team": [doc.memberHandle] },
grant: { roles: { owner: ["admin:grants"] } },
};
}
if (doc.type === "final-results") {
ctx.requireRole("feedback-team");
return { channels: [doc._id], grant: { public: [doc._id] } };
}
throw { forbidden: "unknown document type" };
}
// Summary: anyone, signed in or not, submits responses that stay as written; the owner names each feedback team member with a team-member record, and the team reads the responses and publishes the final results for everyone. A team-member record names its person in memberHandle.
Key patterns: allowAnonymous: true on survey-response lets unauthenticated visitors submit, grant.public on final-results makes them readable by any member without a specific channel grant, the singleton grant doc pattern (survey-config) wires role-to-channel access in one place, and each team-member record the owner writes puts one person in that role through members — so the team the config lets in has people in it, and removing the record lets that person go.
App.jsx — anonymous-friendly submit form, owner-only config panel, role-gated results view:
jsx<<<<<<< SEARCH
const { database } = useFireproof("notes");
=======
const { database, useLiveQuery } = useFireproof("survey");
const { me, can } = useVibe("survey");
// Submit form is shown for anyone — allowAnonymous makes this can.create return ok for null user.
const canSubmit = can.create({ type: "survey-response", question: "q1", answer: "" });
// Owner-only — survey-config rule calls ctx.requireRole("owner").
const canConfigure = can.create({ type: "survey-config" });
const { docs: results } = useLiveQuery("type", { key: "final-results" });
>>>>>>> REPLACE
jsx<<<<<<< SEARCH
<Field label="Your answer">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={answer} onChange={setAnswer} />}</Field>
=======
{/* anyone (signed in OR anonymous) can submit — only stamp authorHandle when present */}
{canSubmit.ok ? (
<form onSubmit={(e) => {
e.preventDefault();
database.put({ type: "survey-response", question: "q1", answer, ...(me && { authorHandle: me.userHandle }) });
}}>
<Field label="Your answer">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={answer} onChange={setAnswer} />}</Field>
<Button type="submit">Submit</Button>
</form>
) : <Text muted>{canSubmit.reason}</Text>}
{/* owner-only admin: wire up the feedback-team role and name who is on the team */}
{canConfigure.ok && (
<ConfigPanel
onAddMember={(handle) =>
database.put({ _id: `team-member:${handle}`, type: "team-member", memberHandle: handle })
}
/>
)}
{/* final results are public: everyone, signed in or not, reads every published result */}
{results.map((r) => <ResultCard key={r._id} doc={r} />)}
>>>>>>> REPLACE
Example: Public guestbook / contact form (anonymous writes)
When the prompt says anyone can sign / submit without logging in (a guestbook, a contact form, an RSVP), do not throw on !user — return allowAnonymous: true so the write is accepted for anonymous visitors. useVibe().can.create(...) then returns ok for an anonymous viewer, and the form shows instead of a sign-in wall. Stamp authorHandle only when there is a user.
access.js
jsexport function guestbook(doc, oldDoc, user, ctx) {
if (doc.type === "entry") {
if (oldDoc) throw { forbidden: "entries are write-once" };
// No `if (!user) throw` — anyone may sign. allowAnonymous opts the write in.
return { channels: ["public"], grant: { public: ["public"] }, allowAnonymous: true };
}
throw { forbidden: "unknown document type" };
}
// Summary: anyone, signed in or not, signs the guestbook and everyone reads it; each entry stays as it was signed.
In App.jsx, gate the form on useVibe("guestbook").can.create({ type: "entry" }).ok (true for anon here) and stamp authorHandle: me?.userHandle only when signed in. Without allowAnonymous: true the runtime rejects the null-user write even though the function didn't throw — so the guestbook would silently require login, the exact miss to avoid.
The app before the edit, a note form wrapped in a sign-in wall:
jsximport { useState } from "react";
import { useFireproof } from "use-fireproof";
import { Field, Input, Stack } from "@vibes.diy/pouf";
import { SignInGate } from "./SignInGate.jsx";
export default function App() {
const { database } = useFireproof("notes");
const [message, setMessage] = useState("");
return (
<Stack>
<SignInGate>
<Field label="Your note">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={message} onChange={setMessage} />}</Field>
</SignInGate>
</Stack>
);
}
App.jsx — gate the form on can.create({ type: "entry" }).ok, which is true even when nobody is signed in:
jsx<<<<<<< SEARCH
import { Field, Input, Stack } from "@vibes.diy/pouf";
=======
import { Button, Field, Input, RowCard, Stack, Text } from "@vibes.diy/pouf";
import { useVibe } from "use-vibes";
>>>>>>> REPLACE
jsx<<<<<<< SEARCH
const { database } = useFireproof("notes");
=======
const { database, useLiveQuery } = useFireproof("guestbook");
const { me, can } = useVibe("guestbook");
const { docs: entries } = useLiveQuery("type", { key: "entry" });
// allowAnonymous: true on the access fn → this returns ok for an anon viewer.
// The form is visible from first load, no sign-in wall.
const canSign = can.create({ type: "entry", message: "" });
>>>>>>> REPLACE
jsx<<<<<<< SEARCH
<SignInGate>
<Field label="Your note">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={message} onChange={setMessage} />}</Field>
</SignInGate>
=======
{canSign.ok ? (
<form onSubmit={(e) => {
e.preventDefault();
// Stamp authorHandle only when signed in — anon entries simply omit it.
database.put({ type: "entry", message, createdAt: Date.now(), ...(me && { authorHandle: me.userHandle }) });
setMessage("");
}}>
<Field label="Your note">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={message} onChange={setMessage} placeholder="Leave a note" />}</Field>
<Button type="submit">Sign</Button>
</form>
) : <Text muted>{canSign.reason}</Text>}
{entries.map((e) => <RowCard key={e._id}><Text>{e.message} — {e.authorHandle || "anonymous"}</Text></RowCard>)}
>>>>>>> REPLACE