vibes.diy/docs Home Discord
creator docs · reference · generated

useVibe Hook — write gating

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/use-vibe.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.

useVibe(dbName) is how you gate write surfaces. It runs the app's own access.js — the same function the server enforces — against a candidate document, so can.* is a hint about whether this person may write a record like this, good for deciding which controls to show. The server checks each change against the saved version of the record, so it has the final word. You never re-implement permissions; you ask the access function.

jsxconst { me, can, ready } = useVibe("comments");

Pass the Fireproof database name you are writing to. You get:

  • can.create(draft) / can.edit(doc) / can.delete(doc) → { ok: boolean, pending?: true, reason?: string }. The verdict has three states, not two — allowed, still resolving, and denied — so branch on pending before ok.
  • ready — false until identity and the access function have resolved. It is the same moment pending marks, so either one can hold the surface.
  • me — { userHandle, displayName? } | null (null = anonymous). For display only.

The three-way branch

While pending is set, show a quiet checking state — never a sign-in prompt, never the raw reason token, and never a blank space where the write surface was. A pending verdict means "we don't know yet", not "you may not"; it resolves on its own within a moment, so anything that reads as a refusal is wrong for that moment, and hiding the surface with nothing in its place makes the app look broken.

Holding on ready works the same way: put if (!ready) return <Skeleton /> above the first can.*(…).ok read, so the surface's place shows a checking state until the verdict lands.

The same order carries a creator-only line. Check ready first and the founder sees a quiet placeholder for that moment, then the invite button; the "Only the founder…" sentence is for a member the verdict actually turns away:

jsximport { useVibe } from "use-vibes";
import { Button, Skeleton, Text } from "@vibes.diy/pouf";

function InviteRow({ guild, onInvite }) {
  const { can, me, ready } = useVibe("quiltGuilds");
  const invite = can.create({ type: "invite", guildId: guild._id, authorHandle: me?.userHandle });
  if (!ready) return <Skeleton variant="row" />;
  if (!invite.ok) return <Text muted>Only the guild's founder sends invites, so ask them to bring in your quilting friends.</Text>;
  return <Button onClick={onInvite}>Invite a quilter</Button>;
}

On a real denial (!ok and no pending), explain it in your app's own words — reason is a machine token, not display copy. Let it inform the sentence you write ("Join the team channel to post here"), and never render it directly: a token like pending or not in channel: team printed into the page is internal vocabulary leaking at a user.

Stamp authorHandle: me.userHandle inside the write an allowed verdict renders — the submit handler of the form can.create let through — so every document lands with its author. Never stamp author fields from me while the verdict is pending — me is null until identity resolves, and a write that skips the gate lands authorless.

Build the candidate from the doc you'll actually write. can.create(draft) runs the access function against draft, so draft must carry the fields the function checks — authorHandle, channelId, etc. A bare can.create({ type: "post" }) gets denied (e.g. "not author") and hides the form even from users who could post. Stamp the same fields you'll put: can.create({ type: "post", channelId, authorHandle: me?.userHandle }). And gate the same database you write to — useVibe(dbName) selects the access function and grants by dbName, so a gate on a different db won't reflect server enforcement.

The rule

Gate every write affordance on can.*. Hold it while pending, and write your own copy when denied. Never branch write permission on viewer, access.hasRole()/access.hasChannel(), or document fields — those drift from what access.js actually does. Rendering other users (authors, rosters) is useViewer()'s <ViewerTag userHandle={...} />, not useVibe. The current viewer's own pill and the "signed in as" label are system chrome in the Vibes Switch (the logo) — don't build them into the app. Asking an anonymous visitor to sign in is a different thing and it IS yours: useViewer().requestLogin(), at the moment their work becomes worth keeping (see use-viewer docs). A resolved denial is one such moment — write the sign-in copy in your app's own words and wire it to that callable, never to a sentence about the logo. (The one exception is inline avatar self-edit: a guarded no-prop {viewer && <ViewerTag />} lets any signed-in member change their own photo in place — see use-viewer docs.)

App.jsx

jsximport { useState } from "react";
import { useVibe } from "use-vibes";
import { Button, Field, Input, Row, Skeleton, Text } from "@vibes.diy/pouf";

function PromptBar({ database }) {
  const { can, me } = useVibe("aestheticBoard");
  const [text, setText] = useState("");
  const v = can.create({ type: "tile", authorHandle: me?.userHandle });
  // 1. still resolving — a quiet placeholder, no refusal wording, no empty hole
  if (v.pending) return <div aria-busy="true"><Skeleton variant="row" /></div>;
  // 2. resolved denial — your words, informed by v.reason, never v.reason itself
  if (!v.ok) return <Text muted>Join the board to add a tile.</Text>;
  // 3. allowed
  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault();
        setText(""); // ready for the next tile at once
        const { ok } = await database.put({ type: "tile", authorHandle: me?.userHandle, text });
        if (!ok) setText((now) => now || text);
      }}
    >
      {/* no current-user pill — identity + sign-in live in the Vibes Switch (the logo) */}
      <Row gap={2} align="top">
        <Field label="New tile">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={text} onChange={setText} placeholder="Add a tile…" />}</Field>
        <Button type="submit">Post</Button>
      </Row>
    </form>
  );
}

Owner-only and role-gated surfaces

Don't gate management UI on a display flag directly. Encode the rule in access.js (e.g. ctx.requireRole("owner") — the owner is auto-seeded into the reserved owner role) and gate the UI on can.* for that database — the verdict reflects the same rule. Per-row edit/delete affordances: {can.edit(doc).ok && <EditButton doc={doc} />}. By default every signed-in visitor is a first-class participant who creates and edits their own data; reserve requireRole("owner") for genuinely owner-published apps and per-object channels for peer-to-peer sharing. Public-vs-private and the allowed-user list are the owner's runtime sharing settings, not access.js.

Some apps need setup before they work

Most apps work the moment they open. Some need their owner to do one thing first — paste a key, save a billing portal, connect an account — and that is a property of the app, not of any one integration, so every app that has it takes the same shape. An app has at most one Setup surface, and the backend's verdict decides its state, never a display flag. The integration skills (Stripe, Spotify, YouTube) each contribute one item to it: what the app needs, how the backend checks it, and the linked steps in the provider's own words.

The backend answers what the app needs and whether each item is done. A /setup route in backend.js, called with apiFetch so ctx.userInfo is set, lists the items — the key is present, the portal is saved, the account is connected — with done read from ctx.secrets or the connection, and ready when every item is done. An item whose verdict costs a provider call (is the portal saved?) is asked on the owner's call alone and kept in a document the backend writes; a visitor's read takes the kept value, so visitor traffic spends nothing of the provider's and a blip there keeps the last verdict. The Setup surface is the owner's alone: the backend decides who the owner is, and non-owners never receive the items. Each step is a link to the exact page in that page's own words, with the vibe's real handle and slug filled in from what the backend hands the client. The route carries ownerHandle and appSlug from ctx.appInfo, so the Settings link (https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings, Secrets) and the CLI line (npx vibes-diy secrets set KEY --vibe <ownerHandle>/<appSlug>) are copy-ready rather than templates.

js// backend.js — the verdict for everyone, the items for the owner alone
if (url.pathname === "/setup" && request.method === "GET") {
  const owner = ctx.userInfo?.userHandle === ctx.appInfo.ownerHandle;
  const items = [{ id: "stripe-key", label: "Stripe secret key", done: Boolean(ctx.secrets.STRIPE_SECRET_KEY) }];
  const ready = items.every((item) => item.done);
  if (!owner) return json({ ok: true, ready, owner });
  return json({ ok: true, ready, owner, items, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
}

What the page shows, in four states. Not done, owner: a Setup entry in the app's own navigation, highlighted while incomplete (an app with no navigation shows a top banner that opens it), and the Setup screen lists each item with its linked steps. Not done, non-owner: the feature reads as not open yet, nothing else. Done, owner: the Setup entry stays, quiet and reachable — later it is where a key is rotated or test turns into live. Done, non-owner: invisible. The owner's steps live on the Setup surface and nowhere else, and a feature that depends on them reads as not open yet in the consumer flow. The consumer flow is the buyer's, the reader's, the listener's, the same page for everyone; a coffee-order page whose step 3, Payment, turned into the owner's three-link chore is the shape this replaces. The page reads the verdict on mount and again whenever it regains focus or becomes visible, so the feature unlocks in place with no reload — the steps open Settings and the provider in other tabs, and the owner comes back to a feature that works.

jsx// App.jsx — one hook, one surface, the verdict decides what each person sees
// (imports: useEffect, useRef, useState from react; apiFetch from use-vibes; pouf components)
function useSetup() {
  const [setup, setSetup] = useState(null);
  const latest = useRef(0);
  useEffect(() => {
    // Mount, focus and visibility reads overlap; only the latest one may land, or a slow stale
    // answer overwrites a fresh one.
    const load = () => {
      const n = ++latest.current;
      apiFetch("/setup").then((r) => r.json()).catch(() => ({ ready: false, owner: false })).then((s) => { if (n === latest.current) setSetup(s); });
    };
    load();
    const onVisible = () => { if (document.visibilityState === "visible") load(); };
    document.addEventListener("visibilitychange", onVisible);
    window.addEventListener("focus", load);
    return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); };
  }, []);
  return setup;
}

function SetupScreen({ setup }) {
  const vibe = `${setup.ownerHandle}/${setup.appSlug}`;
  return (
    <Stack>
      <Heading>Setup</Heading>
      {setup.items.map((item) => (
        <Stack key={item.id}>
          <Row gap={2}><Text>{item.label}</Text><Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : "To do"}</Badge></Row>
          {/* the skill's steps: each one a link to the exact page, in that page's own words */}
          <ol>
            <li>Open Stripe's <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noreferrer">API keys page (test mode)</a> and press <b>Reveal test key</b> beside <b>Secret key</b>.</li>
            <li>Open <a href={`https://vibes.diy/vibes/mine/${vibe}/settings`} target="_blank" rel="noreferrer">this vibe's Settings tab</a>; under <b>Secrets</b>, enter <code>STRIPE_SECRET_KEY</code>, paste the value, and save.</li>
          </ol>
        </Stack>
      ))}
    </Stack>
  );
}

export default function App() {
  const setup = useSetup();
  const [screen, setScreen] = useState("home");
  return (
    <Stack>
      <Row gap={2}>
        <Button variant="quiet" onClick={() => setScreen("home")}>Home</Button>
        {setup?.owner && <Button variant={setup.ready ? "quiet" : "solid"} onClick={() => setScreen("setup")}>Setup{setup.ready ? "" : " · to do"}</Button>}
      </Row>
      {screen === "setup" && setup?.owner ? <SetupScreen setup={setup} /> : setup?.ready ? <PayButton /> : <Text muted>Checkout is not open yet.</Text>}
    </Stack>
  );
}

Not the same thing: a visitor's first-run answers — who are you, which team — are per-person onboarding (the pouf onboarding block), saved as that person's own document, and stay as they are. Owner setup is the app's, done once, and lives on the Setup surface.

The server is still the authority

can.* is a fast preview, not the final word. A write can still be refused server-side (the server compares the change with the saved record, and the source may be stale, async, or unevaluable — in which case can.* optimistically returns ok and defers to the server). Write optimistically: put() saves locally at once and never rejects for access. A refused write converges to the server's version on its own, and the platform tells the visitor, so most apps need nothing more. For feedback beside the control, read the put's receipt (const { synced } = await database.put(doc), then synced.on(...)), or doc._synced.status on a row — see the fireproof skill.