---
name: quickback
description: Quickback documentation - use when working with Quickback projects, defining resources, schemas, security pillars (Firewall, Access, Guards, Masking), actions, the Stack runtime services, or deployment
allowed-tools: Read, Grep, Glob, Edit, Write
---

# Quickback

Quickback is a **backend compiler**. Define your database schema and security rules in TypeScript; compile them into either a Hono API or PostgreSQL Row Level Security policies. Pair the output with the Quickback Stack for a complete Supabase alternative on Cloudflare.

It's two products:

## 1. Quickback Compiler — the build tool

Define schema + security in TypeScript. Compile to one of two targets:

- **Quickback for Hono API** — Cloudflare D1 or Neon. Full Hono application with CRUD, batch endpoints, OpenAPI, and an MCP server.
- **Quickback for Supabase** — PostgreSQL Row Level Security policies. Keep Supabase Auth, Storage, and Realtime; Quickback adds the security layer.

The same `defineTable()` definitions work for both targets. The compiler runs as a remote service at `compiler.quickback.dev` (override with `QUICKBACK_API_URL`) — the CLI is a thin client that sends config + features and writes the generated files.

## 2. Quickback Stack — the runtime services

Everything you need around the compiled API for a complete Supabase alternative on Cloudflare:

| Service | What it gives you | Implementation |
|---|---|---|
| **Auth** | Email OTP, passkeys, password, Google OAuth, organizations, admin | Better Auth |
| **Storage** | KV (sessions, cache) and R2 (files) | Cloudflare KV + R2 |
| **Realtime** | WebSocket for live updates | Durable Objects |
| **Queues** | Background jobs, webhook delivery | Cloudflare Queues |
| **Schedules** | Cron jobs via `defineSchedule` | Cron Triggers |
| **Email** | Inbound handlers via `defineEmail` | Email Workers |
| **Vector & AI** | Embeddings, semantic search | Workers AI |
| **Webhooks** | Signed inbound, retried outbound | Hono + Queues |
| **CMS** | Schema-driven admin UI | Embedded SPA |
| **Account UI** | Login, profile, orgs, admin | Embedded SPA |

Everything runs on the user's own Cloudflare account — no Quickback infrastructure between them and their data.

---

## Quickstart

```bash
npm install -g @quickback-dev/cli
quickback create cloudflare my-app
cd my-app
quickback compile
wrangler deploy
```

Three concept files do most of the work:

- `quickback.config.ts` — project config (template, providers, account UI, CMS, etc.)
- `quickback/features/<feature>/<table>.ts` — table schema + security in one file via `feature()` (or `defineTable` for Drizzle interop)
- `quickback/features/<feature>/actions/<name>.ts` — custom endpoints via `defineAction()` (one file per action)

---

## What `quickback compile` regenerates (and what it never touches)

`quickback compile` is a **pure code generator**. It reads your `quickback/`
definitions, sends them to the compiler service, and writes output to disk. It does
**not** connect to, migrate, or read any database — that is a separate, explicit
deploy step.

Each compile rewrites two trees:

- **`src/`** — the entire generated worker (routes, middleware, auth, MCP server,
  `env.d.ts`, etc.). Fully regenerated every run. Never hand-edit it; your changes
  are overwritten — put custom code in `quickback/` (features, actions, hooks,
  `quickback/lib/`) and recompile.
- **`quickback/drizzle/<db>/`** (`auth`, `features`, `webhooks`, `audit`, `files`) —
  Drizzle migration `.sql` + meta snapshots. **Incremental**: the CLI loads your
  existing committed meta and sends it along, and the compiler diffs it against the
  current schema and emits only the migration(s) for what changed. With no prior
  meta it emits fresh `CREATE TABLE`s; with meta, only the delta. **Commit
  `quickback/drizzle/` to git** — it is the source of truth for "what schema the last
  compile already knows about."

Compile does **not** apply migrations, touch D1/Neon, or mutate data.

### How compile meets an already-migrated prod DB

Applying migrations is a distinct, **idempotent** step — `wrangler d1 migrations
apply <db> --remote` (the project's deploy script runs it for every DB before the
worker flip). D1's `d1_migrations` table records what's applied, so already-applied
migrations are no-ops and only pending ones run. The safe loop against a live prod
DB:

1. Edit `quickback/` definitions.
2. `quickback compile` → regenerates `src/` and **appends only the new migration(s)**
   to `quickback/drizzle/` (diffed from your committed meta — it does not rewrite or
   replay history).
3. Deploy → apply pending migrations (no-op for any prod already has), **then**
   `wrangler deploy` the worker, so schema moves ahead of or with the code.

Migrations are additive-only by convention, so a freshly compiled worker stays
compatible with the prod schema across the apply→deploy window. **Caveat:** if
`quickback/drizzle/` meta is not committed, the next compile can't see prior state
and regenerates full `CREATE TABLE`s — which then collide with the live DB. Keep the
meta in git.

---

## Tenancy is not yours to model

**Workspace, team, tenant, account, org — they are all the Better Auth organization.**
Quickback ships multi-tenancy. Building your own is the single most expensive mistake in a
Quickback project, and the compiler will fight you the whole way.

- **Never** create a `workspaces` / `teams` / `tenants` / `accounts` table.
- **Never** create a membership or `*_members` table for it.
- **Do** put `organizationId: q.scope("organization")` on every tenant-scoped table. The
  firewall is auto-derived from it, and the column is auto-populated on insert.
- Membership and roles arrive on every request as `ctx.activeOrgId` and `ctx.roles`. Never
  query for them, and never write an action that checks them by hand.
- Create a tenant with `POST /auth/v1/organization/create`; switch with
  `POST /auth/v1/organization/set-active`. Both already exist — don't write an action for it.
- Invitations, roles, and member management are Better Auth's too (`/auth/v1/organization/*`),
  and the Account SPA already has UI for all of it.

If the product calls it something else, that's a **label, not a schema decision** — the Account
UI renames the organization surfaces (`quickback docs ui/account/customization`).

## The authorization ladder

Four rungs, in order. **Most apps never leave rung 1.** Climb only when the rung you are on
genuinely cannot express the rule.

| Rung | Reach for it when | Skip it when |
|---|---|---|
| **1. Org roles + firewall** | `organizationId: q.scope('organization')` plus `roles: ['member+']` / `['admin+']`. No `authz` block at all. | — this is the floor, and it covers most apps end to end |
| **2. Named rules** (`authz.roles` / `authz.rules`) | the same role expression repeats across many features | only a couple of repeats — inline them |
| **3. Relationships + areas** (`authz.relationships`, `_area.ts`) | membership lives in **your own** domain table (attendee, collaborator, external reviewer), or a subtree shares a URL prefix **and** a per-row admission gate | org membership already answers it — that's rung 1 |
| **4. FGA** (OpenFGA / Zanzibar) | you need a permission *graph*: derived, inherited, hierarchical, public share links | a single hop answers it |

Two constraints that make rung 3 the wrong tool for org membership:

- A relationship's `from:` must be one of **your feature tables**. The compiler rejects a
  relationship pointing at Better Auth's `organization_memberships`, `organizations`, or
  `user` — so "is this person in the org?" is never a relationship. It's already in `ctx.roles`.
- A relationship's `where:` takes **scalar equality only** (`string | number | boolean`).
  `where: { role: ['admin','owner'] }` is not valid; declare one relationship per role, or use
  org roles.

`quickback docs define` for the full ladder; `define/access`, `define/areas`, `define/fga` per rung.

---

## The security model

Every API request passes through four pillars (the "Definitions"):

```
Request → Firewall → Access → Guards → Database → Masking → Response
            │          │        │                     │
            │          │        │                     └── Hide PII fields
            │          │        └── Block field modifications on writes
            │          └── Check roles & record-level conditions
            └── WHERE-clause data isolation (org / owner / team / soft-delete)
```

| Pillar | Purpose |
|---|---|
| **Firewall** | Compiled WHERE clauses — scope reads/writes to the caller's org, owner, or team. |
| **Access** | Role-based + record-level permissions; **deny by default**. |
| **Guards** | Field-write protection: `createable`, `updatable`, `immutable`, `protected` (action-only). |
| **Masking** | PII redaction; auto-applied for sensitive column names. |

Plus **Views** (named projections with their own access) and **Actions** (custom endpoints with declarative access, transitions, unsafe escape hatch).

**Secure by default.** Nothing is reachable until you explicitly open it. A fresh resource definition produces 401/403 for every caller that isn't an admin.

For the full reference, run `quickback docs define` (or any specific pillar: `firewall`, `access`, `guards`, `masking`, `views`, `actions`).

---

## A complete feature

```ts
// quickback/features/todos/todos.ts
import { feature, q } from "@quickback/compiler";

export default feature("todos", {
  columns: {
    id:             q.id(),
    title:          q.text().required(),
    completed:      q.bool().default(false),
    ownerId:        q.scope("owner"),         // → ctx.userId, auto-firewalled, auto-populated
    organizationId: q.scope("organization"),  // → ctx.activeOrgId, same
  },
  // firewall + soft-delete derived from q.scope() above:
  //   [{ field: 'organizationId', equals: 'ctx.activeOrgId' },
  //    { field: 'ownerId',        equals: 'ctx.userId' },
  //    { field: 'deletedAt',      isNull: true }]
  read: { access: { roles: ["member", "admin"] } },
  crud: {
    create: { access: { roles: ["member", "admin"] } },
    update: { access: { roles: ["admin"] } },
    delete: { access: { roles: ["admin"] }, mode: "soft" },
  },
  guards: {
    createable: ["title", "completed"],
    updatable:  ["title", "completed"],
  },
});
```

```ts
// quickback/features/todos/actions/complete.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { todos } from "../todos";

export default defineAction({
  description: "Mark todo as complete",
  input: z.object({}),
  access: {
    roles: ["member", "admin"],
    record: { completed: { equals: false } },
  },
  async execute({ db, record, whereRecord }) {
    await db.update(todos)
      .set({ completed: true })
      .where(whereRecord!(todos));
    return { success: true };
  },
});
```

For the schema column types: `quickback docs define/schema`.
For action shapes (record-based vs standalone, transitions, `unsafe:`): `quickback docs define/actions`.
For Drizzle interop (existing `sqliteTable` schemas): `quickback docs define/escape-hatches`.

---

## Defaults the compiler applies

Before flagging "missing X", check whether a default already covers it:

- **Soft-delete is the default.** `deletedAt` / `deletedBy` are auto-injected at the SQL layer; firewall auto-AND-merges `deletedAt IS NULL`. Opt out with `crud.delete.mode: 'hard'`.
- **Audit fields auto-injected.** `createdAt`, `createdBy`, `modifiedAt`, `modifiedBy` — never declare them in user code. The audit-wrapper auto-populates `createdBy` / `modifiedBy` from `ctx.userId`.
- **Sensitive columns auto-masked.** `email`, `phone`, `ssn`, `password`, `creditCard`, etc. get `show: { or: 'owner', roles: ['admin'] }` by default; falls back to `roles: ['admin']` only when there's no `ownerId` column. Absence of a `masking:` block does NOT mean plain-text — it means the compiler-default is in effect. Declare `masking:` only to override.
- **Firewall auto-derived** for tables with a single isolation column (e.g., `organizationId` only).
- **Soft-delete cascade** reaches cross-feature children with each child's own firewall AND-merged.
- **Reserved keys in `access.record`.** `and`, `or`, `roles`, `userRole` are rejected inside `record:` at compile time — those go on the parent access node.
- **Soft-delete × `unique()` footgun.** A soft-deleted row still occupies its unique index — deleting a unique junction row (e.g. a membership pair) then re-creating it fails the constraint, because the old row is only flagged `deletedAt`, not removed. For re-creatable unique rows, either use `delete.mode: 'hard'` (needs the audit DB on D1) or drop the unique constraint and enforce uniqueness in an action.

---

## The scoped DB — what it does and doesn't do

The action context's `db` is a Drizzle wrapper that:

- **Does** auto-AND the resource's firewall WHERE clause into every `select` / `update` / `delete`.
- **Does** route soft-delete `delete()` calls to a `deletedAt` / `deletedBy` UPDATE.
- **Does** auto-populate scope columns on `insert`: any `q.scope()` column (and firewall-declared org/owner/team columns) is filled from ctx — `organizationId` ← `ctx.activeOrgId`, `ownerId` ← `ctx.userId`, etc. Audit fields (`createdAt`/`createdBy`/`modifiedAt`/`modifiedBy`) are hard-stamped by the audit wrapper; caller-supplied values for them are dropped.
- **Does NOT** fill ordinary business columns — anything that isn't a scope or audit column is exactly what you pass to `.values({...})`.

For unscoped writes (cross-tenant support, platform admin), use the `unsafe:` object form on the action — it forces an audit reason and gates on platform admin role.

## Error handling in actions

To return a clean 4xx from an action, throw `ActionError` — the route handler catches it by name and returns `{ error, code, details }` with your status:

```ts
import { ActionError } from "@quickback/compiler"; // rewritten at compile to the generated src/lib/types

throw new ActionError("Not enough balance", "INSUFFICIENT_FUNDS", 400, { available: record.balance });
```

Constructor: `(message, code, statusCode = 400, details?)`. Do **NOT** throw Hono's `HTTPException` — it isn't unwrapped and surfaces as a 500 `ACTION_EXECUTION_FAILED`. Returning `c.json({...}, 400)` directly also works (the executor receives `c`).

## Reaching auth tables (org members, users)

The scoped feature `db` never includes Better Auth tables — that is by design.
`AUTH_DB` is a separate Drizzle client.

**Caller identity — use `ctx`, do not query AUTH_DB.** `ctx.userId`,
`ctx.activeOrgId`, `ctx.roles`, and `ctx.userRole` are already on the request
(there is no `ctx.orgId`).

To check that a *submitted* userId is a member of the caller's org, use the
Hono context's auth DB plus the generated helper — from an **action**:

```ts
import { getOrgMemberRole } from "../../../lib/org-access";

const role = await getOrgMemberRole(c.get("authDb"), input.assigneeId, ctx.activeOrgId!);
if (!role) throw new ActionError("Not a member of this organization", "NOT_A_MEMBER", 400);
```

That is the native membership check. For a richer query from an `actions/`
file, import tables from `../../../auth/schema`. On D1 the tables are `user`,
`organization`, and `member` — not `organization_membership`.

Account hooks get `{ authDb, authSchema }` injected. Trigger `handler:` args
are `{ ctx, db, env }` — `db` is features only; there is no `c` and no
`authDb`. Prefer `ctx` for the acting user. Do not import `../../auth/schema`
from a table file to reach AUTH_DB.

---

## Quickback CLI (commands the user runs)

```bash
quickback compile           # compile project (prompts to log in if not authenticated)
quickback create <template> # new project from template
quickback login             # authenticate via browser device flow
```

The CLI calls `compiler.quickback.dev` by default. Override with `QUICKBACK_API_URL=http://localhost:3000` for self-hosted compilation.

If you have shell access (e.g. Claude Code), run these commands directly — `quickback compile` is interactive-safe and prompts for login in the browser. In environments without a shell, surface the commands to the user as instructions instead.

---

## Local smoke testing (wrangler dev)

Three things block a working local loop that aren't obvious from the docs:

1. **`BETTER_AUTH_SECRET` must be in `.dev.vars`.** The worker refuses to serve with an explicit `503 MISSING_ENV` without it. Create `.dev.vars` next to `wrangler.toml`: `BETTER_AUTH_SECRET=<any long random string>`.
2. **Auth cookies are Secure + origin-checked.** When testing with curl, capture the `Set-Cookie` from sign-in and pass it back manually, AND send a trusted `Origin:` header (e.g. `Origin: http://localhost:8787`) on auth POSTs or Better Auth's CSRF check rejects them.
3. **Set an active organization before minting a JWT.** `POST /api/v1/token` exchanges the session for a JWT; if the session has no active org, the token carries no org roles and every org-scoped endpoint 403s. Call `POST /auth/v1/organization/set-active` (body: `{"organizationId": "..."}`) first.

```bash
# sign up → sign in (capture cookie) → set active org → mint JWT → call API
curl -X POST http://localhost:8787/auth/v1/sign-up/email -H "Content-Type: application/json" \
  -H "Origin: http://localhost:8787" -d '{"email":"a@b.co","password":"password123","name":"A"}'
curl -i -X POST http://localhost:8787/auth/v1/sign-in/email -H "Content-Type: application/json" \
  -H "Origin: http://localhost:8787" -d '{"email":"a@b.co","password":"password123"}'
# → copy better-auth.session_token from Set-Cookie into $COOKIE
curl -X POST http://localhost:8787/auth/v1/organization/set-active -H "Content-Type: application/json" \
  -H "Origin: http://localhost:8787" -H "Cookie: $COOKIE" -d '{"organizationId":"<org-id>"}'
curl -X POST http://localhost:8787/api/v1/token -H "Cookie: $COOKIE"
```

---

## Looking up details

Two ways to pull reference material on demand; the skill body above is intentionally just orientation.

**Primary: the CLI docs command** (works wherever the CLI is installed):

```bash
quickback docs                                  # topic index
quickback docs define/firewall                  # full topic page
```

Always pass the **full path** (`define/actions`, not `actions`) — short names error as ambiguous when more than one topic matches.

**Secondary: bundled markdown files.** If a `docs/` directory exists alongside this `SKILL.md` (installed by `quickback claude install` / present in the skill package):

```
Glob:  docs/**/*.md                    # discover topics
Read:  docs/define/firewall.md
Grep:  "deletedAt" docs/                # search across the whole docs tree
```

If `docs/` is missing from your installed copy, fall back to `quickback docs <topic>` — don't go hunting for the files.

The path layout mirrors the public docs site (https://docs.quickback.dev). Open any topic file for the authoritative reference.

**Define** (`docs/define/<topic>.md`)
- `schema` — column types, `q.text/int/bool/uuid/timestamp/json/scope/...`, FKs, indexes
- `feature` — `feature()` shape, `q.scope()`, soft delete, audit fields
- `escape-hatches` — when `feature()` can't represent the file: `defineTable()`, raw Drizzle child tables
- `security` — the security overview: rows / who / columns / fields, and the owner-column rule
- `firewall` — predicates, scope detection, exceptions
- `access` — roles, record predicates, role hierarchy, `PUBLIC`, pseudo-roles
- `guards` — createable / updatable / immutable / protected
- `masking` — types, `show:`, search/filter gating, auto-detection
- `rate-limit` — request caps
- `read` — `read.access`, `read.views`, `read.query`, pagination + filtering
- `views` — named projections with column-level access
- `bundles` — single-round-trip bootstrap reads
- `triggers` — Postgres-style table triggers: `sql:` → real SQLite trigger (all write paths, atomic), `handler:` → app hook at the write chokepoint (before mutate/reject, awaited after); plus after-commit hooks
- `transitions` — state-machine policy: guards, stamps, undo pairs, table-level generation
- `changesets` — `owns` composition boundary; atomic parent+children writes via the changeset media type (lids, If-Match, Problem pointers)
- `actions/` — a section, not one page: `actions/index`, `actions/defining`, `actions/record-and-standalone`, `actions/access`, `actions/scoped-db`, `actions/examples`
- `validation` — minLength, max, enum, pattern, email
- `scopes` — relationship-derived scopes (`ctx.scope.*`), scoped roles, JWT claims
- `permissions` — named `authz.permissions`, Policy IR, `{ permission: ... }` arms
- `arrows` — arrow (`->`) traversals in authz expressions, recursive relations
- `fga` — OpenFGA/Zanzibar ReBAC: `authz.fga` model, `ctx.fga`, sharing
- `areas` — feature areas (`_area.ts`), hoisted namespaces
- `diagnostics` — `[quickback:authz]` compile warnings, static analysis
- `encryption` / `sealed-protocol` — field encryption and the sealed tier

**Configure** (`docs/configure/<topic>.md`)
- `providers` — runtime / database / auth provider selection
- `auth` — `account.auth.*`, role hierarchy, plugin toggles
- `variables` — env vars, secrets
- `bindings` — extra Cloudflare bindings (queues, R2, etc.)
- `domains` — multi-domain hostname routing (CMS / account / admin)
- `security` — security-related project config
- `single-tenant` — pinned organization mode and migration away from removed `organizations: false`
- `agents` — robots.txt, llms.txt, MCP, OAuth discovery

**Use the API** (`docs/api/<topic>.md`)
- `crud` / `query-params` / `batch-operations` — the generated endpoints
- `views` / `actions` — calling views and actions over HTTP
- `caching` — ETag and `If-Match`
- `typescript` / `openapi` — generated client types and spec
- `errors` — error codes and envelopes

**Platform services** (`docs/platform/<topic>.md`)
- `cloudflare` — the Workers runtime target
- `database/{index,d1,neon}` — D1 and Neon/Postgres
- `auth` — Better Auth integration, JWT optimization, plugins, device-auth, security
- `storage/{kv,r2}` — using KV and R2 in actions
- `realtime` — Durable Objects, WebSocket
- `queues` — handlers, retries, dead-letter
- `vector/embeddings` — auto-embedding columns, semantic search
- `webhooks/{inbound,outbound}` — compile-declared inbound verification (`standard-webhooks` / `hmac-sha256` / `aws-sns`), Standard Webhooks-signed retried outbound
- `api-contract` — the HTTP contract layer: RFC 9457 Problem Details, keyset cursors, `Idempotency-Key`, `?include=`/sparse fields, the contract `v1`→`v2` matrix
- `push` — Web Push (VAPID + aes128gcm), queue fan-out, identity-tag targeting, hardened subscriptions schema
- `schedules` — `defineSchedule` cron jobs
- `email` — `defineEmail` inbound Email Worker handlers

**Bundled UIs** (`docs/ui/<topic>.md`)
- `admin` — schema-driven admin UI, dashboard, record layouts, table views, schema registry
- `account` — login, profile, orgs, admin, passkeys, API keys, CLI authorize

**Start here** (`docs/start/<topic>.md`)
- `index` — what Quickback is, and the vocabulary the other pages assume
- `quickstart` — scaffold to a live local API
- `deploy` — `quickback deploy`: provision, migrate, ship
- `templates` — full template list
- `template-cloudflare` — recommended starting point
- `template-blog` / `template-empty` — pinned-org blog and bare-config starts
- `first-api` — top-to-bottom walkthrough
- `hand-crafted` — building from `template-empty`
- `patterns` — common multi-tenant / hierarchical / public patterns

**Tooling** (`docs/tooling/<topic>.md`)
- `cli` — full CLI reference
- `output` — `build.outputDir`, custom dependencies
- `troubleshooting` — compile/auth failure modes
- `claude-skill` / `claude-code` — this skill
- `cloud-compiler/{index,authentication,endpoints,local-compiler,troubleshooting}` — the hosted build service

**Other** — `docs/changelog/index.md`

For online navigation: <https://docs.quickback.dev>.
