vibes.diy ← Creator Documentation
/access.js

Private channels.
Anonymous submissions.
Role-gated reads.

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.

Open the visual spec →
file structure

One file, named exports

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 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 {};
}
// /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");
signature

Function signature

(doc, oldDoc, user: UserContext | null, ctx: Helpers) => AccessDescriptor;
ArgumentDescription
docThe document being written
oldDocThe previous version — null for new documents
userThe authenticated user (UserContext), or null for anonymous requests
ctxServer-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.

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 };type 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
    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.

// /access.js 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 {}; }// /access.js
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 {};
}
example

Anonymous survey with role-gated results

Unauthenticated visitors submit responses. Admins and the feedback team can view raw data. Published results become fully public.

// /access.js export function survey(doc, oldDoc, user, ctx) { if (doc.type === "survey-response") { if (oldDoc) throw { forbidden: "responses are write-once" }; return { channels: ["inbound-responses"], allowAnonymous: true }; } if (doc.type === "survey-config") { if (!user?.isOwner) throw { forbidden: "owner only" }; return { grant: { roles: { "feedback-team": ["inbound-responses"], }, }, }; } if (doc.type === "final-results") { ctx.requireRole("feedback-team"); return { channels: [doc._id], grant: { public: [doc._id] } }; } if (!user) throw { forbidden: "authentication required" }; return {}; }// /access.js
export function survey(doc, oldDoc, user, ctx) {
  if (doc.type === "survey-response") {
    if (oldDoc) throw { forbidden: "responses are write-once" };
    return { channels: ["inbound-responses"], allowAnonymous: true };
  }

  if (doc.type === "survey-config") {
    if (!user?.isOwner) throw { forbidden: "owner only" };
    return {
      grant: {
        roles: {
          "feedback-team": ["inbound-responses"],
        },
      },
    };
  }

  if (doc.type === "final-results") {
    ctx.requireRole("feedback-team");
    return { channels: [doc._id], grant: { public: [doc._id] } };
  }

  if (!user) throw { forbidden: "authentication required" };
  return {};
}
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.

// /access.js export function chat(doc, oldDoc, user, ctx) { if (!user) throw { forbidden: "authentication required" }; ctx.requireAccess(doc.channelId); return { channels: [doc.channelId] }; } export function notes(doc, oldDoc, user, ctx) { if (!user) throw { forbidden: "authentication required" }; return {}; }// /access.js
export function chat(doc, oldDoc, user, ctx) {
  if (!user) throw { forbidden: "authentication required" };
  ctx.requireAccess(doc.channelId);
  return { channels: [doc.channelId] };
}

export function notes(doc, oldDoc, user, ctx) {
  if (!user) throw { forbidden: "authentication required" };
  return {};
}
catch-all

Catch-all with export default

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.

// /access.js export function chat(doc, oldDoc, user, ctx) { if (!user) throw { forbidden: "authentication required" }; ctx.requireAccess(doc.channelId); return { channels: [doc.channelId] }; } // Everything else: require authentication, no channel routing export default function (doc, oldDoc, user, ctx) { if (!user) throw { forbidden: "authentication required" }; return {}; }// /access.js
export function chat(doc, oldDoc, user, ctx) {
  if (!user) throw { forbidden: "authentication required" };
  ctx.requireAccess(doc.channelId);
  return { channels: [doc.channelId] };
}

// 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.

Open the visual spec →

Start writing access functions

Create /access.js in your vibe, add a named export for each database, and push. The server discovers it automatically.