backend.js — the app's server-side backend
prompts/pkg/llms/backend.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.
backend.js is an optional server-side file, a sibling of access.js. It
runs on the platform's servers — never in the browser — and gives the app three
server superpowers, each an exported function:
fetch(request, ctx)— answer HTTP requests at the app's/_api/...URL (webhooks from outside, orfetch("/_api/…")from the app itself).onChange(event, ctx)— react after a document write commits (derive, aggregate, moderate, fan out).scheduled(event, ctx)— run periodically (cleanup, digests, timers).
Export only the handlers the app needs. Most apps need no backend.js at
all — normal CRUD flows through Fireproof directly. Reach for it only when the
app needs a server-side action: accepting a webhook, deriving/aggregating
documents in reaction to writes, or periodic work.
Output format
backend.js is a separate file, exactly like access.js: one prose line, the
filename backend.js on its own line, then one complete fenced block (the
whole file — don't use SEARCH/REPLACE for it; re-emit the full file to change
it). Never put backend code inside an App.jsx block, and never import
backend.js from App.jsx — the browser can't run it.
Writes go through the access function
Every ctx.db.put/ctx.db.delete is enforced by the app's own access.js —
the exact gate user writes pass through — acting as the trigger's identity:
| Handler | Writes act as… |
|---|---|
onChange |
the user whose write triggered the event |
fetch |
the signed-in caller when verifiable — treat as possibly anonymous |
scheduled |
the app owner, in admin mode |
So the permission model stays in access.js, and backend.js writes must be
allowed by it for the acting identity. If a fetch handler writes, make sure
the access function permits that write for an anonymous caller (or design the
write to happen in onChange/scheduled, which carry stronger identities).
The flip side: the access function cannot tell a backend write from a user
write — that's the invariant. An onChange write acts as the triggering user,
so anything it may write, that user's own client could write too. onChange is
for derivation and convenience, never privilege escalation. For documents
only the server should control, use scheduled — it acts as the owner, an
identity access.js can genuinely restrict a database to.
scheduled always runs in admin mode, the same override the owner gets
from the client's admin toggle: ctx.requireAccess(...)/ctx.requireRole(...)
no-op, and its ctx.db.query reads are unfiltered. The access function still
runs on every write — its returned channels and grants still route the doc,
so members keep seeing what the cron writes. user.isOwner is true, so
owner-conditional rules apply as the owner. Two consequences worth designing
around: a scheduled sweep can read and rewrite every user's documents in
the app (access functions protect users from each other, not from the app
owner — the owner's automation touches all users' data); and because admin mode
skips requireAccess, a bug in a scheduled write fails silently-wrong rather
than forbidden — keep scheduled writes small and well-guarded. fetch and
onChange never get admin mode: they're user-triggerable, and a handler any
visitor can invoke must not run owner-privileged.
ctx — what a handler gets
jsctx.appInfo; // { ownerHandle, appSlug } — this app's identity
ctx.userInfo; // { userHandle } or null — who the handler is acting as
ctx.secrets; // Record<string,string> — the owner's per-vibe secrets; frozen, {} when none
await ctx.db.put(doc, { db: "notes", id: "optional-id" }); // resolves to the doc id AFTER commit
await ctx.db.delete(docId, { db: "notes" });
const docs = await ctx.db.query({ db: "notes" }); // latest non-deleted docs (each with _id)
const text = await ctx.callAI("prompt", { model: "openrouter/auto", max_tokens: 500 }); // server-side AI call
{ db }names the Fireproof database (same namesApp.jsxuses withuseFireproof). It is required — except insideonChange, where the database that triggered the event is the default.- Always
awaitdb calls; they resolve only after the write commits (or throw when the access function denies it). ctx.db.query({ db })returns the whole database's latest docs (capped at- — filter and sort in the handler. It is read-ACL-gated as the acting
identity and anonymous
fetchcallers are denied. Inscheduled(admin mode) the read is unfiltered — access-fn-bound databases return every doc, across all users and channels. Infetch/onChange, databases bound to an access function cannot be queried (keep those lanes' read databases on plain ACLs). Made forscheduledsweeps: read, decide, thenput/delete.
- — filter and sort in the handler. It is read-ACL-gated as the acting
identity and anonymous
ctx.secretscarries the owner's per-vibe secrets, set in the vibe's settings page or withvibes-diy secrets set KEY. Use it to verify inbound credentials — a webhook's shared secret or token — or to authenticate outbound calls (Authorization: Bearer ${ctx.secrets.KEY}to an admitted API). Never write a value anywhere users can read it (a doc, a response body). Only the owner can set or rotate secrets; handlers see the current values on every invocation.- Outbound
fetch()is policy-gated:backend.jscan call external https APIs that either (a) are CORS-open — the platform forwards the request exactly as a browser on the app's own page could (same preflight rules, sameAccess-Control-Allow-Origincheck, browser-forbidden headers likeCookieare stripped) — or (b) are on the platform's curated list (api.github.comis seeded; additions are a PR to the platform repo). A denied call returns403 {"vibesEgressDenied":true,"gate":"floor"|"owner-blocked"|"cors"|"rate-limit","host":...}— check for it and surface a useful error. Remedies: use a CORS-open endpoint, request a curated-list addition, or ask about owner blessing. Limits: https on port 443 only, 15s per request, 10MB responses, per-vibe rate caps (30/10s, 300/min). Make all outbound calls before returning the Response — afetch()fired while a streamed body is still being pulled after the handler returned is unsupported and gets denied. Interactive frontend AI calls stay inApp.jsxviacallAI; server-side AI belongs toctx.callAI. ctx.callAI(prompt, options?)is the server-side AI call. It resolves to the completion text (a plain string) — no API key needed and none exists in the handler; the platform makes the call and meters the cost against the account of the user who triggered the handler (the writer inonChange, the signed-in caller infetch), or the app owner when no user is involved (scheduledticks, anonymous webhooks). Options:model(default"openrouter/auto"),max_tokens,temperature. No streaming and no schema mode — for structured output, ask for JSON in the prompt andJSON.parsedefensively. Budget: a handful of calls per invocation (currently 5) and ~64k prompt chars; a denied or failed call throws — catch it and degrade (e.g. write the doc back with an error field) rather than losing the event. Keep interactive/streaming AI inApp.jsxviacallAI; usectx.callAIwhen the result must be server-authoritative (moderation, digests, summaries users shouldn't be able to forge).
fetch — the app's HTTP endpoint
Runs for requests to the app's /_api route. The request path is rooted after
/_api: a call to https://slug--owner.host/_api/webhooks/pay arrives with
pathname /webhooks/pay. Return a standard Response.
jsexport async function fetch(request, ctx) {
const url = new URL(request.url);
if (url.pathname === "/rsvp" && request.method === "POST") {
const body = await request.json();
const id = await ctx.db.put({ kind: "rsvp", name: body.name, at: Date.now() }, { db: "rsvps" });
return new Response(JSON.stringify({ ok: true, id }), {
headers: { "content-type": "application/json" },
});
}
return new Response("not found", { status: 404 });
}
From App.jsx, call it with a relative fetch — no host needed:
jsconst res = await fetch("/_api/rsvp", { method: "POST", body: JSON.stringify({ name }) });
onChange — react to committed writes
Runs after any document write to the app's databases commits (user writes and backend writes alike). The event:
jsexport async function onChange(event, ctx) {
// event: { dbName, docId, doc, oldDoc, seq, deleted }
if (event.dbName !== "votes" || event.deleted) return;
// Maintain a server-authoritative tally the UI reads but users can't forge.
await ctx.db.put({ _id: "tally-" + event.doc.pollId, kind: "tally", bump: event.seq }, { db: "tallies" });
}
Two rules keep change-reactions sane:
- Guard on
event.dbNamefirst. The handler fires for every database, so an unguarded write-back loops on itself. - Write derived docs to a different database than the one that triggered
the event. A backend write triggers
onChangeagain (one generation deeper); the platform caps runaway chains after a few generations, but a tight same-database ping-pong is still wasted work. Different db + dbName guard makes loops structurally impossible.
scheduled — periodic work
Requires a config export with a static string-literal interval between
"5s" and "1h" (e.g. "30s", "5m", "1h" — computed values are rejected):
jsexport const config = { scheduled: { interval: "15m" } };
export async function scheduled(event, ctx) {
// event: { scheduledTime } — ISO timestamp of this tick
await ctx.db.put({ kind: "heartbeat", at: event.scheduledTime }, { db: "status" });
}
Runs as the app owner in admin mode (see "Writes go through the access
function" above): it can read every doc in the app and its writes pass
requireAccess/requireRole automatically, while the access function's
channels and grants still route what it writes. Use it for cleanup, digests,
rotations, and time-based state — not for anything that needs a user in the
loop.
A complete example — activity feed + owner-only digest
Users write note docs from App.jsx. The backend does two jobs: onChange
mirrors each note into a per-author activity entry (acting as that author —
same privilege, just automated), and a scheduled sweep maintains one digest
doc that access.js restricts to the owner, so no user can forge it.
jsexport const config = { scheduled: { interval: "15m" } };
export async function onChange(event, ctx) {
if (event.dbName !== "notes" || event.deleted) return;
await ctx.db.put(
{ _id: "act-" + event.docId, kind: "activity", srcId: event.docId, by: ctx.userInfo, at: event.seq },
{ db: "activity" }
);
}
export async function scheduled(event, ctx) {
// Acts as the app owner — access.js allows `digest` docs for the owner only,
// making this document genuinely server-controlled.
await ctx.db.put({ _id: "digest", kind: "digest", updatedAt: event.scheduledTime }, { db: "digest" });
}