Build workspace chat where each channel is private. Run surveys anyone can answer but only admins can read. Gate dashboards by team role. One file, server-enforced — no backend to write.
📐
Visual Specification
Color-coded document cards, flow diagrams, channel membership visuals, and the full runtime architecture — everything on this page, illustrated.
Access functions live in /access.js, 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 for any database without its own named export.
// /access.js — each export name = the database it gates
export function chat(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "authentication required" };
if (doc.type === "message") {
if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
ctx.requireAccess(doc.channelId);
return { channels: [doc.channelId] };
}
return {};
}// /access.js — each export name = the database it gates export functionchat(doc, oldDoc, user, ctx) { if (!user) throw { forbidden: "authentication required" }; if (doc.type === "message") { if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" }; ctx.requireAccess(doc.channelId); return { channels: [doc.channelId] }; } return {}; }
// /App.jsx — no access option needed; the server matches by database name
const { useLiveQuery, database } = useFireproof("chat");// /App.jsx — no access option needed; the server matches by name const { useLiveQuery, database } = useFireproof("chat");
The authenticated user (UserContext), or null for anonymous requests
ctx
Server-provided helpers for checking materialized grant state
UserContext:
{ userHandle: string// stable unique id — use for all auth checks displayName?: string// display only — never use for identity checks isOwner: boolean// true when this user owns the vibe — use for management gates }
Helpers (ctx): Opaque closures over materialized grant state. They throw or pass — you cannot enumerate channels, list members, or iterate grants.
ctx.requireAccess(channelId) — throws if user is not in the channel
ctx.requireRole(roleName) — throws if user does not have the role
return type
AccessDescriptor
All fields are optional. {} is a valid return. throw { forbidden: "reason" } rejects the write.
type AccessDescriptor = {
channels?: string[]; // route this doc to channels
members?: Record; // role membership (reduced by union)
grant?: {
users?: Record; // direct user → channel grants
roles?: Record; // role → channel grants
public?: string[]; // member-public read — any member, no channel grant needed
};
expiry?: string | number | null; // ISO date or unix seconds
allowAnonymous?: boolean; // opt-in for null-user writes
};typeAccessDescriptor = { 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 roles?: Record<roleName, string[]>; // role → channel grants public?: string[]; // public read — no auth required }; expiry?: string | number | null; // ISO date or unix seconds allowAnonymous?: boolean; // opt-in for null-user writes };
key concepts
How it all fits together
Channels
Route documents. A document with channels: ["general"] is only visible to users who have been granted access to "general". Channels are the unit of read isolation.
Grants are additive
The effective access state is the union of every current document's AccessDescriptor. No "remove grant" operation — deleting a document drops its contribution automatically. Revocation is deletion.
Grant resolution
Two passes: first expand grant.roles through members, then union with grant.users direct grants. Roles and direct grants compose, never override.
allowAnonymous
If user is null and the function returns without throwing, the runtime checks allowAnonymous. Without it, the write is rejected — prevents silent open anonymous writes. grant.public makes a channel readable by any member (no channel grant needed); anonymous write requires allowAnonymous: true separately. Whether non-members can read public channels depends on the app-level public toggle.
example
Workspace chat with channels
A single access function handles three document types: channel creation with member grants, messages routed to channels, and invites that extend access.
allowAnonymous: true on survey-response lets unauthenticated visitors submit
Checking oldDoc makes responses write-once — prevents clients from overwriting existing submissions
grant.public on final-results makes them readable by any member without a channel grant
user.isOwner on survey-config restricts configuration to the vibe owner
The singleton grant doc pattern (survey-config) wires role-to-channel access in one place
multi-database
Multiple databases in one file
Each named export gates its own database. Databases without a matching named export fall through to export default if one exists. If there is no default export either, the database uses default app-level permissions.
Use export default to gate every database without writing a named export for each one. Named exports still take precedence for databases that need custom logic. Especially useful when an app has many databases or uses hyphenated names (error-log, user-prefs) that can't be JavaScript identifiers.
// Everything else: require authentication, no channel routing export default function (doc, oldDoc, user, ctx) { if (!user) throw { forbidden: "authentication required" }; return {}; }
patterns
Roles via members reduce
Roles aren't a fixed registry. They're materialized from document contributions — each document can add handles to a role.
// A team-meta doc contributes members to a role
if (doc.type === "team-meta") {
ctx.requireRole("admin");
return {
members: { [doc.teamId]: doc.memberHandles },
grant: { roles: { [doc.teamId]: doc.channels } },
};
}
// A per-employee membership doc contributes one handle
if (doc.type === "membership") {
return { members: { [doc.role]: [doc.userHandle] } };
}// A team-meta doc contributes members to a role if (doc.type === "team-meta") { ctx.requireRole("admin"); return { members: { [doc.teamId]: doc.memberHandles }, grant: { roles: { [doc.teamId]: doc.channels } }, }; }
// A per-employee membership doc contributes one handle if (doc.type === "membership") { return { members: { [doc.role]: [doc.userHandle] } }; }
📐
Full Visual Specification
Color-coded document cards, flow diagrams, channel membership visuals, and runtime architecture details. Covers every use case with styled interactive examples.