Firefly Access Function v2 — user.userHandle · named exports

Workplace chat — one database, channels as the isolation unit

What this is. Firefly is the database and sync layer built into vibes.diy. Access functions live in /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.

The door and the room. Access functions are the room — they govern what members can do with data once inside the app. The per-vibe membership system is the door — it decides who can see the app at all. Once a user is approved as a member (through the door), the access function is the sole authority for data permissions. The door's only job is: are you in or out? 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.

📦 useFireproof("chat")  ·  gated by: export function chat(...) in /access.js
channel-meta
_id: chan-general
ownerHandle: "alice"
memberHandles: ["bob","carol"]
message
userHandle: "bob"
channelId: "chan-general"
text: "hey everyone"
channel-invite
senderHandle: "carol"
inviteeHandle: "dave"
channelId: "chan-general"

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.

⚡ /access.js — named export per database, runs on every write including delete
export function chat(doc, oldDoc, user: UserContext | null, ctx: Helpers): AccessDescriptor
UserContext
userHandle: "bob"  // stable unique id — use for all auth checks
displayName?: "Bob Smith"  // natural data — never use for identity checks
isOwner: boolean  // true when this user owns the vibe — use for management gates

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
requireAccess(channelId)  // throws if user not in channel — opaque, not enumerable
requireRole(roleName)     // throws if user not in role    — opaque, not enumerable

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.

channel-meta
throw if doc.ownerHandle ≠ user.userHandle
throw if oldDoc && oldDoc.ownerHandle ≠ user.userHandle

channels: [doc._id]
grant: memberHandles → doc._id
grant: ownerHandle → doc._id

delete → grants recomputed → members lose access ✓
message
throw if doc.userHandle ≠ user.userHandle
ctx.requireAccess(doc.channelId)

channels: [doc.channelId]
grant: (none)

read gated by existing channel membership
channel-invite
throw if doc.senderHandle ≠ user.userHandle
ctx.requireAccess(doc.channelId)

channels: [doc.channelId]
grant: inviteeHandle → doc.channelId

any member can invite; delete revokes invite grant
⚡ /access.js — workspace chat (export function chat)
export function chat(doc, oldDoc, user, ctx) { if (!user) throw { forbidden: "authentication required" } 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") { if (doc.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" } ctx.requireAccess(doc.channelId) return { channels: [doc.channelId], grant: { users: { [doc.inviteeHandle]: [doc.channelId] } } } } return {} }

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.

Resulting channel access (materialized from current docs)

chan-general
alice bob carol dave ← via invite

Derived from channel-meta grants + invite grants. Recomputed on every write.

chan-engineering
alice dave

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.

Key flows

1 — Member discovers new channel
📝alice writeschannel-meta
access fn runsgrants bob, carol
🔄bob's next querysees channel-meta
💬sidebar updates#chan-general appears
2 — Invite flow (any member can invite)
📨carol writeschannel-invite (dave)
access fn checkscarol.userHandle ∈ channel ✓
🔑grant issueddave → chan-general
👀dave can readall channel history
3 — Revocation (channel deleted or memberHandles updated)
🗑️alice deleteschannel-meta
access fn runsreturns {} grants
📊grants recomputedbob, carol removed
🚫next querychannel gone from sidebar

Complete API v2

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.

⚡ (doc, oldDoc, user: UserContext | null, ctx: Helpers) → AccessDescriptor
type Helpers = { requireAccess: (channelId: string) => void // throws if user not in channel — opaque, not enumerable requireRole: (roleName: string) => void // throws if user not in role — opaque, not enumerable } type AccessDescriptor = { channels?: string[] // route this doc — per-doc, not reduced members?: Record<roleName, userHandle[]> // role membership — reduced by union grant?: { users?: Record<userHandle, string[]> // direct grants — reduced by union roles?: Record<roleName, string[]> // role grants — reduced by union public?: string[] // member-public read — any member, no channel grant needed } expiry?: string | number | null // ISO date or unix seconds allowAnonymous?: boolean // opt-in: runtime rejects null-user writes if absent } throw { forbidden: "reason" } // rejects write — not a return field // default behaviour — both helpers also throw if user is null if (!user) throw { forbidden: "authentication required" }

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.

server reduces across all current docs — delete drops contribution automatically
members reduce
effectiveMembers[role]
= union of all docs'
members[role] arrays

delete → handle disappears ✓
grant.roles resolve
grant.roles[role] channels
× effectiveMembers[role]
= per-user channel set

two-pass expansion
grant.users direct
grant.users[handle] channels
unioned on top of
role-expanded channels

DMs, one-off grants

Use Case — Employee Onboarding

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.

📦 four doc types, four access function returns
role-channels (singleton, admin writes once)
type: "role-channels"
role: "global-team"
channels: ["announcements","handbook","it-help","all-hands"]
grant.roles["global-team"] → doc.channels
membership (one per employee, written on hire)
type: "membership"
userHandle: "newperson"
role: "global-team"
members["global-team"] → ["newperson"]
team-meta (manager edits memberHandles)
type: "team-meta"
teamId: "design-team"
memberHandles: ["alice","bob","newperson"]
channels: ["design-general","design-reviews","design-assets","design-critique"]
members["design-team"] → memberHandles
grant.roles["design-team"] → doc.channels
dm (app creates on first message)
type: "dm"
participants: ["alice","newperson"]
channels → ["dm-alice-newperson"]
grant.users["alice"] → ["dm-alice-newperson"]
grant.users["newperson"] → ["dm-alice-newperson"]

Reduced state after onboarding

members
global-team → [...all, newperson]
design-team → [alice, bob, newperson]
pdx-crew    → [...12..., newperson]
grant.roles
global-team → [announcements, handbook, it-help, all-hands]
design-team → [design-general, design-reviews, design-assets, design-critique]
pdx-crew    → [pdx-lunch, pdx-commute, pdx-events … ×12]
Onboarding — two writes, 20 channels
👤HR createsmembership doc
🎨design mgr addsnewperson to team-meta
📊reduces recomputemembers + grants
newperson syncs4 + 4 + 12 channels

PDX manager adds newperson independently. Each write is atomic — no coordination needed between managers.

Offboarding — delete membership doc, remove from team-meta
🗑️HR deletesmembership doc
🎨mgr removes handlefrom team-meta
📊reduces recomputenewperson → {}
🚫next syncall channels gone

DM docs persist — offboarding policy decides whether to delete them separately.

Two valid membership topologies — same access function, same reduce
Team-centric (decentralized)
Team manager edits team-meta doc.
memberHandles lives on the team.
Manager controls their own roster.
Employee-centric (admin)
HR edits employee doc, roles field.
Membership tags live on the person.
Central HR controls all memberships.

Both produce identical reduced state. Can be mixed: HR owns global-team via employee tags, design manager owns design-team via team-meta.


Use Case — Survey App

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.

📦 useFireproof("survey")  ·  gated by: export function survey(...) in /access.js
survey-questions (admin writes, team-only on inbound-responses)
_id: survey-q-2026
type: "survey-questions"
questions: […]
if (!user.isOwner) throw { forbidden: "owner only" }
channels: ["inbound-responses"]
survey-response (anonymous submit via allowAnonymous, owner + feedback-team read)
type: "survey-response"
answers: […]
throw if oldDoc  // responses are write-once
channels: ["inbound-responses"]
allowAnonymous: true  // explicit opt-in; grant via survey-config
survey-config (singleton — admin writes once)
_id: "survey-config"
type: "survey-config"
if (!user.isOwner) throw { forbidden: "owner only" }
grant.roles: feedback-team → inbound-responses
final-results (feedback-team writes, any member reads)
_id: results-2026
type: "final-results"
summary: {…}
ctx.requireRole("feedback-team")
channels: [doc._id]
grant.public: [doc._id]

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.

⚡ /access.js — survey (export function survey)
export function survey(doc, oldDoc, user, ctx) { if (doc.type === "survey-questions") { if (!user?.isOwner) throw { forbidden: "owner only" } return { channels: ["inbound-responses"] } // owner-only — no grant.public } if (doc.type === "final-results") { ctx.requireRole("feedback-team") return { channels: [doc._id], grant: { public: [doc._id] } } // published results — any member can read } if (doc.type === "survey-response") { if (oldDoc) throw { forbidden: "responses are write-once" } return { channels: ["inbound-responses"], allowAnonymous: true } // explicit opt-in — runtime rejects without this } if (doc.type === "survey-config") { if (!user?.isOwner) throw { forbidden: "owner only" } return { grant: { roles: { "feedback-team": ["inbound-responses"] } } } } if (!user) throw { forbidden: "authentication required" } // default gate return {} }
1 — Admin saves questions (team-only)
👤admin writessurvey-questions
isOwner checkowner ✓
📥routed toinbound-responses
🔒owner +
feedback-team read
via survey-config grant
2 — Anonymous visitor submits response
🌐visitor posts_id: null
_id check passesnull user allowed
💾server assigns IDrouted to inbound-responses
🔒admin reads allvisitor sees nothing

Visitor submitting with a self-chosen _id throws immediately — prevents targeting or overwriting any response.

3 — Authenticated user tries to forge response ID
😈client sets_id: "target-id"
access fn runsoldDoc exists (update attempt)
🚫throw"responses are write-once"

Survey Variants — open vs invite-only

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.

📋 survey s1 — open responses
question
surveyId: "s1"
open: true
text: "Rate our service"
if (!user.isOwner) throw { forbidden: "owner only" }
channels: ["s1-questions"]
grant.public: ["s1-questions"]
open-response (allowAnonymous: true, write-once)
surveyId: "s1"
answers: […]
throw if oldDoc
channels: ["s1-responses"]
allowAnonymous: true
🔒 survey s2 — invite-only
question
surveyId: "s2"
open: false
text: "Evaluate the candidate"
if (!user.isOwner) throw { forbidden: "owner only" }
channels: ["s2-questions"]
grant.roles: survey-s2-responders → s2-questions
invite (team member invites responder)
surveyId: "s2"
senderHandle: "alice"
inviteeHandle: "bob"
if (!user.isOwner) throw { forbidden: "owner only" }
throw if doc.senderHandle ≠ user.userHandle
members: { survey-s2-responders: ["bob"] }
channels: ["s2-admin"]
response (invited users only, write-once)
surveyId: "s2"
answers: […]
throw if oldDoc
ctx.requireRole("survey-s2-responders")
channels: ["s2-responses"]

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.

⚡ /access.js — all doc types, both survey variants (export function survey)
export function survey(doc, oldDoc, user, ctx) { const id = doc.surveyId if (doc.type === "question") { if (!user?.isOwner) throw { forbidden: "owner only" } // team-only write; doc.open is trustworthy const ch = `${id}-questions` return { channels: [ch], grant: doc.open ? { public: [ch] } // s1: any member reads : { roles: { [`survey-${id}-responders`]: [ch] } } // s2: invited only } } if (doc.type === "invite") { if (!user?.isOwner) throw { forbidden: "owner only" } if (doc.senderHandle !== user?.userHandle) throw { forbidden: "not sender" } return { channels: [`${id}-admin`], members: { [`survey-${id}-responders`]: [doc.inviteeHandle] } } // grants invitee the responder role } if (doc.type === "open-response") { // s1 — explicit opt-in for anonymous submit if (oldDoc) throw { forbidden: "responses are write-once" } return { channels: [`${id}-responses`], allowAnonymous: true } } if (doc.type === "response") { // s2 — invited users only if (oldDoc) throw { forbidden: "responses are write-once" } ctx.requireRole(`survey-${id}-responders`) return { channels: [`${id}-responses`] } } if (doc.type === "survey-config") { if (!user?.isOwner) throw { forbidden: "owner only" } return { grant: { roles: { [`survey-${id}-team`]: [`${id}-responses`] } } } } if (doc.type === "results") { if (!user?.isOwner) throw { forbidden: "owner only" } return { channels: [`${id}-results`], grant: { public: [`${id}-results`] } } } if (!user) throw { forbidden: "authentication required" } return {} }
s1 — open: anonymous visitor responds
🌐member readss1-questions (public channel)
📝posts open-response_id: null, user: null
💾server assigns ID→ s1-responses
📊team readsvia survey-config grant
s2 — invite-only: alice invites bob, bob responds
📨alice writesinvite (bob)
members reducebob ∈ s2-responders
🔑bob readss2-questions
💾bob respondsrole check passes

Alice deletes the invite doc → bob drops from s2-responders reduce → can no longer submit. Already-submitted responses remain.

Summary. The access function API has three surfaces: the return value (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.

Runtime Architecture — Named Exports + Per-Database DO

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.

⚡ putDoc handler — access function call path
// 1. look up binding for this database (D1) const afb = await getAccessFunctionBinding(ownerHandle, appSlug, dbName) // 2. route to per-database DO — DO evals function source via QuickJS const doId = env.ACCESS_FN_DO.idFromName(`${ownerHandle}/${appSlug}/${dbName}`) const stub = env.ACCESS_FN_DO.get(doId) const result = await stub.fetch("/invoke", { body: { doc, oldDoc, user, source: afb.source } }) // 3. if DO needs hydration (cold start), fetch all docs and hydrate if (result.needsHydrate) { const docs = await fetchAllDocs(ownerHandle, appSlug, dbName) await stub.fetch("/hydrate", { body: { docs, source: afb.source } }) } // 4. reject or continue if (result.forbidden) return json({ forbidden: result.forbidden }, 403)

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.