Workplace chat — one database, channels as the isolation unit
/access.js, a separate file in the vibe's filesystem alongside /App.jsx. Each
named export maps to a database name — export function chat(...) gates
useFireproof("chat"). An export default function acts as a catch-all, gating any database
without its own named export; named exports always take precedence. The server
discovers /access.js automatically and runs the matching export on every write — including deletes — before storing
the document. It is the single checkpoint for validation, routing, and access grants. This document specifies the access function
API and its return type, AccessDescriptor.
grant.public means "any member can read this channel" — no specific channel grant
needed beyond membership. Whether the door itself is open to the world (public toggle ON) or requires approval (public toggle
OFF) is a separate, app-level decision.
The first example is a workplace chat app. The database holds three document types that together implement channel-based access control. Channels are the unit of read isolation: a document routed to a channel is only visible to users who have been granted access to that channel.
Document types. The app code assigns channel ids. In this app channel-meta owns a channel. The doc
_id is used directly as the channel name in the access function return — channels: [doc._id].
_id is the document's identity key; it can be a human-readable slug ("chan-general"), a natural key, a
foreign key from another system, or a platform-generated opaque ID. Any unique value works. Using doc._id as the
channel name enforces uniqueness: it avoids a redundant channelId field when the document's identity and its channel
name are the same thing. A message carries a single chat post and holds a channelId foreign key pointing
at a channel-meta _id. A channel-invite extends read access to a new user. The access function's job is
to validate each write and declare how it affects the grant state.
Identity. userHandle is the stable unique identifier for an authenticated user. It is the only
field the access function should use for identity checks. displayName is natural data; it changes when users rename
themselves and must never anchor an authorization decision. user is null for unauthenticated
(anonymous) requests; the default behavior is to throw in that case.
Helpers are opaque. ctx.requireAccess(channelId) and ctx.requireRole(roleName)
are server-provided closures over the current materialized grant state. They throw or they don't — the access function cannot
enumerate channels, list members, or iterate the grant map. Both helpers also throw automatically when
user is null, so any branch that calls them is protected against anonymous access without an explicit null check.
This is intentional: the helpers are a yes/no validation surface, not a queryable store.
Return value semantics. channels routes this specific document — any user with channel access
receives it on their next sync. The grant fields (grant.users, grant.roles, members) are
additive contributions to a server-side materialized view: the effective access state at any moment is the union
reduction of the access function's output across every current document. This is the key property that makes revocation automatic.
Deleting a document drops its contribution from the reduction — no explicit "remove grant" step is needed or possible.
Derived from channel-meta grants + invite grants. Recomputed on every write.
Separate channel-meta doc, separate grant set. bob & carol have no access.
The channel memberships shown are derived purely from the current document set. No separate access control list is maintained
alongside the data. If alice's channel-meta doc is deleted, the reduction over remaining docs yields no grants for
chan-general, and bob and carol lose access on their next sync — automatically, with no separate revocation step.
This contrasts with Couchbase Sync Gateway's access(), where grants from deleted documents persist in the grant store
and must be explicitly retracted. The declarative return-value model fixes this: the grant state is a pure function of the current
document set, not an accumulation of past writes.
Full AccessDescriptor — all fields optional, {} is valid, throw rejects the write
The workplace chat example used a subset of the return type. There is no larger API hidden elsewhere: the access function's entire
surface is the return value plus throw. There are no imperative helpers analogous to Sync Gateway's
channel() or access() side-effect calls — routing and grants are expressed as return fields only. The
per-vibe membership system (the door) controls who can see the app at all. When an access function exists for a database, it is
the sole authority for that database's read and write permissions — the room inside the door.
Anonymous writes require explicit opt-in. If user is null and the access function
returns without throwing, the runtime checks result.allowAnonymous. If it is absent or false, the
write is rejected with { forbidden: "authentication required" }. This closes the footgun where a natural arrow
function (doc => ({ channels: [doc.type] })) silently opens anonymous writes because it never inspects
user. When user is not null, allowAnonymous has no effect — it is safe to always include
it on branches that can be reached by either authenticated or anonymous writers. grant.public on a channel makes
it readable by any member (anyone through the door) without a specific channel grant; anonymous write is
always gated separately by allowAnonymous: true. Whether anonymous visitors (non-members) can also read public
channels depends on the app-level public toggle — that's the door's decision, not the access function's.
Roles are not a fixed registry. Because members and grant.roles both reduce across
current docs, role membership is a materialized aggregate of document contributions — not a separately administered list. A
team-meta doc with three entries in memberHandles contributes three handles to the
members["design-team"] reduce. A singleton role-channels doc contributes channel grants for that role. The server
resolves effective per-user channel access in two passes: first expand grant.roles through members,
then union with grant.users direct grants.
New hire gets 4 mandatory + 4 design + 12 PDX channels from two manager edits
This example extends the model to an organizational hierarchy. A new employee needs company-wide mandatory channels managed centrally by HR, team channels managed by the design lead, and location channels managed by the PDX office. Each manager writes their own doc type independently — no coordination between them is required. The access model stitches the contributions together via the reduce.
Global team membership uses the singleton pattern: one role-channels doc declares what channels the
role grants, and one small membership doc per employee contributes that employee's handle to the role. The membership
doc is written on hire and deleted on offboarding — the role loses the handle contribution automatically. Contrast this with the
design team's team-meta doc, which carries both membership and channel grants in one place. Both patterns produce
identical reduced state; the choice is organizational, not technical.
PDX manager adds newperson independently. Each write is atomic — no coordination needed between managers.
DM docs persist — offboarding policy decides whether to delete them separately.
Both produce identical reduced state. Can be mixed: HR owns global-team via employee tags, design manager owns design-team via team-meta.
Anonymous submit + role-gated read + public results
The survey app demonstrates the two access patterns not present in the workplace chat example: unauthenticated writes (where
user is null) and public read via grant.public. It also shows the
singleton grant doc pattern, where role-to-channel wiring lives in one admin-owned document rather than being
repeated across every data document.
Singleton grant doc. The survey-config doc is the only place that declares who can read
inbound-responses. Because the grant reduce is additive and idempotent, it would be technically valid to include the
same role grant on every response doc — but 1,000 response docs redundantly asserting the same grant.roles entry is
wasteful. The singleton keeps wiring in one place: changing who reads responses means updating one doc, not rewriting the entire
response set.
Server-generated IDs. Requiring doc._id to be falsy prevents clients from choosing response
identifiers. A client that sets _id to a known value could overwrite an existing response (update attack) or fetch it
directly by ID (enumeration attack). Throwing at the access function layer stops both before storage.
Visitor submitting with a self-chosen _id throws immediately — prevents targeting or overwriting any response.
Same database, same access function — role and channel names scoped to survey ID
When a database holds multiple surveys, all role and channel names must be scoped to the survey ID to prevent cross-survey data
leakage. A generic survey-participant role spanning all surveys would let a participant in survey A read questions
from survey B. The pattern survey-{id}-team, survey-{id}-responders, {id}-questions,
{id}-responses makes each survey a self-contained access namespace within the shared database.
Two survey modes are shown. An open survey (s1) routes its questions to a
grant.public channel — any member can read without a specific channel grant. An invite-only survey
(s2) routes questions to a role-gated channel; a team member must write an invite doc to extend
survey-s2-responders membership to each respondent. The invite doc is a membership grant doc: writing it adds the
invitee to the role via the members reduce, and deleting it removes them.
Why doc.open on the question doc is trustworthy. The access function dispatches on
doc.type and derives the grant shape from doc.open. This field is set by the writer — but only the
owner can write question docs (user.isOwner enforces this). A non-owner cannot write a
question doc at all, so they cannot spoof doc.open. The security property follows from the write gate, not from
trusting the field in isolation.
Alice deletes the invite doc → bob drops from s2-responders reduce → can no longer submit. Already-submitted responses remain.
channels,
members, grant.{users,roles,public}, expiry, allowAnonymous) which drives
routing and grants; throw which rejects writes; and the
ctx helpers (requireAccess, requireRole) which query materialized state opaquely. All
grant fields reduce by union across current docs. Deletion is automatic revocation. user is null for anonymous
requests; the runtime rejects null-user writes unless the return value explicitly sets allowAnonymous: true — this
prevents the common footgun where a function returns without inspecting user and inadvertently opens anonymous
writes. 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 (the door). Anonymous write is always governed
by allowAnonymous independently. Role and channel names should be scoped to application-level identifiers (survey
ID, team ID) to prevent cross-namespace leakage in shared databases.
The access function must evaluate server-side to be a real security boundary. Client-side evaluation (sending the
AccessDescriptor result from the browser) is not a security boundary — a client can send any descriptor it likes. The
design below achieves server-side evaluation with per-database isolation and a grant reduce that materializes channel membership.
Named exports, not default export. /access.js uses named exports only — each export name maps to a
database name. At push time, the server parses the module with QuickJS to extract export names, filters out JS built-in globals
(toString, constructor, etc.), and creates a binding row per valid export. Databases without a matching
export have no access function — no DO is created, no evaluation runs, no performance overhead.
Per-database Durable Object. Each DO is keyed by ${userHandle}/${appSlug}/${dbName} and holds the
grant reduce for exactly one database. On every write, the putDoc handler looks up the binding row (D1) and routes to
the matching DO. The DO evaluates the access function source using QuickJS WASM — a sandboxed JS VM that runs
inside the Worker. The ctx.requireAccess and ctx.requireRole helpers are registered as QuickJS host
functions that call back into the DO's materialized grant state.
Hydration protocol. When a DO evicts and restarts (cold start), it has no grant state. The first write returns
{ needsHydrate: true }. The caller fetches all current docs for the database and sends them via
POST /hydrate. The DO uses blockConcurrencyWhile during hydration — concurrent writes queue behind it
and proceed once the reduce is populated. Only one hydration runs; subsequent requests see the hydrated state.
Security model. Access function evaluation uses QuickJS WASM — a sandboxed JavaScript VM that runs inside the Cloudflare Worker. The access function cannot access Worker globals, environment bindings, or network. The security property is twofold: the client cannot bypass the policy, and the policy code is sandboxed from the Worker runtime.