# v0.32 – v0.39

## v0.39.1 — June 2, 2026

Deduplicate colliding OpenAPI `operationId`s. The auto-generated list id
`list<Resource>` collided when a user named an action `list<Resource>` (e.g.
`message_reactions` + an action `listMessageReactions`), producing a duplicate
operationId that failed OpenAPI validation and broke `api-types.gen.ts`. The
user's action keeps its id; the colliding auto-generated CRUD op is suffixed,
with a `[quickback:openapi]` warning per rename.

## v0.39.0 — June 2, 2026

Media and large-binary uploads are now first-class. Instead of streaming file
bytes through your Worker — which hits the request-body cap and burns Worker
CPU/duration — an action mints a short-lived **presigned URL** and the client
uploads the bytes **directly to R2**. The Worker only signs; it never sits in
the data path. This is the supported way to author image/video/document upload
endpoints, and it survives every `quickback compile` with no generated-file
patching.

Requires R2 file storage (`defineFileStorage("cloudflare-r2", …)`).

### `ctx.storage` — sign uploads from any action

When R2 file storage is configured, every `defineAction` execute receives a
typed `storage` signer. Your action runs all the usual security layers (auth,
roles, org/relationship scope), computes a tenant-scoped key, hands the client
an upload URL, and stores the key in **your own table** — no separate metadata
system required:

```ts title="features/media/actions/uploadFileBinary.ts"
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Mint a presigned R2 upload URL for an event media file.",
  path: "/event/:eventId/media/upload-file-binary",
  method: "POST",
  input: z.object({ filename: z.string(), contentType: z.string() }),
  access: { roles: ["admin", "member", "scope:event:attendee"] },
  async execute({ input, ctx, c, db, storage }) {
    const eventId = c.req.param("eventId");
    const key = `org/${ctx.activeOrgId}/event/${eventId}/${crypto.randomUUID()}-${input.filename}`;
    const upload = await storage.signPutUrl(key, { contentType: input.contentType, expiresIn: 600 });
    await db.insert(images).values({ key, eventId });
    return { uploadUrl: upload.url, key, headers: upload.headers, expiresAt: upload.expiresAt };
  },
});
```

`storage` exposes `signPutUrl(key, { contentType?, expiresIn? })` (returns
`{ url, method, headers, key, expiresAt }`) and
`signGetUrl(key, { expiresIn?, downloadFilename? })` (returns a presigned GET
URL). Construction is zero-cost — actions that never touch `storage` pay
nothing.

### Managed presign + confirm endpoints

For the zero-config path, the built-in storage API now exposes a presigned
upload alongside the existing through-Worker upload:

- `POST /storage/v1/presign/:bucket/*path` — runs the bucket's auth/role/scope
  checks, validates the declared `contentType` + `size` against bucket limits,
  records a pending object row, and returns `{ id, key, uploadUrl, method,
  headers, expiresAt }`
- `POST /storage/v1/confirm/:bucket/*path` — reconciles the recorded size and
  etag against what actually landed in R2 (`HEAD`)

### Setup: R2 API token

Presigning uses R2's S3 API, which needs an **R2 API token** — the Workers R2
binding cannot presign on its own. Set three secrets:

```bash
wrangler secret put R2_ACCOUNT_ID
wrangler secret put R2_ACCESS_KEY_ID
wrangler secret put R2_SECRET_ACCESS_KEY
```

`aws4fetch` is added to the generated `package.json` automatically, and the R2
presign secrets are added to the generated `Env` type. If you pass `contentType`
to `signPutUrl`, the client must replay exactly that `Content-Type` header on
the `PUT`.

### No raw-body action flag

Streaming a raw binary request body through an action is intentionally **not**
supported. Presigning delivers the same result — large files, no body cap —
without poking a hole in the body-parsing layer, so JSON actions and the ~1 MiB
JSON cap stay intact for every other route.

### Storage routes hardened

Enabling R2 file storage previously exposed several latent type errors in the
generated `/storage/v1/*` routes (dead references from an old multipart
refactor, JSON-string role columns treated as arrays, a few undefined
variables). The full storage surface now type-checks cleanly when file storage
is configured.

## v0.38.1 — May 26, 2026

Custom action handlers get the same typed ergonomics as generated CRUD paths:
typed Cloudflare bindings on `env`, a first-class typed `realtime` helper when
realtime is enabled, and no more `createRealtime(...)` boilerplate inside every
action just to emit an explicit broadcast.

### Neon compile output and local recompile state are now stable

The Neon + Cloudflare path now emits a correct runtime/output surface instead
of leaking SQLite-era state.

- `wrangler.toml` no longer emits a stray `[[d1_databases]]` block for Neon
  projects
- repeated local Neon compiles now upload and sync the root PostgreSQL Drizzle
  state correctly, so `drizzle-kit` can report "No schema changes" instead of
  regenerating duplicate `0000_*.sql` files
- stale root Drizzle migrations from earlier bad compiles are pruned during the
  compile/sync path

### Shared-target namespace hydration now works for bulk-grant lanes

Bulk-grant namespace callers (`{ roles: [...] }`, `{ team: true }`) now inherit
the namespace's shared hydration target when every relationship lane points at
the same resource load shape.

That fixes the case where a namespace like:

```ts
via: ['attendeeOf', 'organizerOf', { roles: ['admin', 'owner'] }]
```

would authorize an org admin through the bulk lane but leave `ctx.event`
undefined, even though the sibling relationship lanes all hydrated the same
`events` row.

Runtime behavior is now split cleanly:

- **Shared target** — if all hydrating lanes share the same `loads`,
  `exposeAs`, and resource key, hydration runs unconditionally after the gate
  passes, so bulk callers get the same `ctx.<exposeAs>` as relationship callers
- **Distinct targets** — if lanes hydrate different tables / keys / ctx slots,
  hydration stays per-matched-lane to avoid cross-table false 404s

Standalone action typing now matches that runtime behavior too: shared-target
hydration remains non-optional, while distinct-target multi-lane hydration stays
optional.

### Platform role split: `appmanager` for control-plane, `sysadmin` for cross-tenant

Quickback no longer treats `user.role === "admin"` as the platform-wide role.
The control-plane role is now `user.role === "appmanager"` (CMS shell, Better
Auth admin endpoints, support tooling), while `user.role === "sysadmin"`
remains the only true cross-tenant data-plane tier.

This also retires the `ADMIN` pseudo-role. Any `roles: ["ADMIN"]` usage now
fails the compile with migration guidance:

- `userRole: ["appmanager"]` for platform control-plane access
- `roles: ["admin"]` for org-membership admin access
- `roles: ["SYSADMIN"]` for true cross-tenant access

Runtime behavior now matches that split end-to-end:

- Better Auth's admin plugin is generated with `adminRoles: ["appmanager", "sysadmin"]`
- CMS / OpenAPI / auth-admin surfaces gate on `appmanager` (and admit
  `sysadmin` where appropriate)
- unsafe cross-tenant actions and tenant-bypass helpers require `sysadmin` only

### Typed `env` + injected `realtime` in action handlers

The generated `<feature>/.quickback/define-action.ts` helper now types action
`env` from the project's runtime bindings surface instead of `any`, so custom
bindings like R2 buckets, queues, Durable Objects, auth env vars, and split DB
bindings no longer need `(env as any)` casts inside action handlers.

When realtime is enabled in config, action handlers also receive a typed
`realtime` helper directly in `execute(...)`. It exposes the same
`insert/update/delete/broadcast` methods as `createRealtime(env)`, but without
the extra import or hand-rolled factory call in every custom action.

This is a DX-only change. The auto-broadcast boundary is unchanged: generated
CRUD routes still broadcast automatically, while custom action writes still
broadcast explicitly.

### `generateId: "cuid"` docs now reflect the split runtime/schema behavior

Quickback's `cuid` strategy is now documented as the split path it actually is:
compiler-managed schema auto-injection keeps the `createId()` call site but may
back it with a file-local `crypto.randomUUID()` polyfill, while import-capable
runtime paths still emit real `createId()` usage.

That matters most for `compiler.injectId` and other schema-emission paths that
run in sandboxes where project `node_modules` are not visible. The generated
schema can safely keep:

```ts
id: text("id").primaryKey().$defaultFn(() => createId())
```

while satisfying the reference via:

```ts
const createId = () => crypto.randomUUID();
```

instead of importing `@paralleldrive/cuid2` inside the schema-generation
environment.

**Important nuance:** this is not a guarantee that every generated id is a true
cuid2 string. Auto-injected schema paths may produce UUID-shaped strings
through the polyfill, while runtime paths that can import helpers may still use
real `createId()`. If you need strict cuid2 shape everywhere, explicitly import
`createId` from `@paralleldrive/cuid2` in the authored schema file; the
compiler preserves that import and skips the polyfill.

## v0.38.0 — May 26, 2026

**Per-matched-lane authorization** for multi-lane namespaces — across both the
REST scope refresh/mint and the realtime ws-ticket. A namespace gated by several
`via:` lanes (attendees *or* organizers *or* contractors *or* org admins) now
confers the scope role of **whichever lane the caller actually matched**, instead
of suppressing the grant. This retires the v0.37.0 single-conferring-lane
limitation.

### Scope refresh & `mintScope` — per matched lane

A **multi-lane `via:`** now refreshes and mints the matched lane's role:
`attendeeOf` → `scope.event.roles = ['attendee']`, `organizerOf` → `['organizer']`.
The resolver records which lane opened the gate (first-match-wins), so a caller
can only ever carry the role they proved this request. A **bulk-grant lane**
(`{ roles: [...] }` / `{ team: true }`) still confers **no** scope role — it
authorizes via the caller's org role, with nothing to stamp. Previously any
multi-lane / bulk / mixed `via:` suppressed the stamp and mint entirely; that
restriction is lifted. See [Scopes](/define/scopes#mintscope-mint-the-scope-token-from-the-namespace-resolver).

### Namespace-backed ws-ticket — realtime, per matched lane

`realtime.wsTicket` accepts a **`namespace`** reference, so the
`/broadcast/v1/ws-ticket` route authorizes through that namespace's full
multi-lane `via:` and issues a per-matched-lane WebSocket ticket — the socket
gate and the REST gate stay identical, from a single source of truth.

```typescript
realtime: {
  wsTicket: { namespace: "eventScope", requestField: "eventId", scopeTable: "events" },
},
```

An attendee gets an `attendee` ticket, an organizer an `organizer` ticket, and an
org admin who reaches the room through the bulk lane now gets a ticket scoped to
their org/membership roles — closing the gap where the single-`role` form `403`'d
a legitimate admin and left their realtime socket dead.

**Tenant-bound, fail-closed.** Before minting, the route loads the `scopeTable`
row through *its own firewall*, so a ticket can only target a room whose resource
resolves under the caller's tenant — an org-A admin requesting an org-B `eventId`
gets `403`, never a cross-tenant socket. Because the room *is* the data (no
firewall sits between a ticket and a Durable Object room), the compiler **rejects
at build time** a namespace-backed `wsTicket` whose `scopeTable` isn't
tenant-scoped (`firewall: { exception: true }` or a literal-only firewall with no
org/owner/team isolation). See
[Realtime tickets](/platform/realtime/durable-objects#namespace-backed-tickets-per-matched-lane).

## v0.37.0 — May 26, 2026

The **authz-as-compile-target** roadmap ships. Your authorization model —
relationships, named permissions, FK-traversal arrows, scope axes — compiles
into one normalized policy IR, lowers to SQL `WHERE` clauses / signed claims /
guards, and is checked by a static analyzer before a single route is emitted.
This release lands milestones M0.1, M0.3, M1, M2, and M4, plus §5.3 namespace
scope-minting.

### Named permissions (`authz.permissions`) — M1

A `via:` arm references **one** relationship. When the *same* grant rule recurs
across resources — "organizer **or** confirmed attendee" — name it **once**
under `authz.permissions` as a boolean expression and reference it everywhere
with a `{ permission }` firewall arm.

```typescript
authz: {
  permissions: {
    'event:view':   { anyOf: ['organizerOf', 'attendeeOf'] },
    'org:admin':    { anyOf: [{ role: 'admin' }, { role: 'owner' }] },
  },
}
// then on a resource:
//   firewall: [ { field: 'eventId', permission: 'event:view' } ]
```

`anyOf` lowers to an OR of relationship subqueries, `allOf` to an AND, and a
`{ permissionRef }` inlines another permission's arms — byte-identical to
writing the `via:` arms by hand. The full DSL (`anyOf` / `allOf` / `not`, bare
strings, and the object-leaf escape hatch) is accepted so configs stay
forward-compatible; a firewall enforces the **SQL-pushable** subset (claim-site
leaves like `{ role }` / `{ scopeRole }` and `not` over a SQL site are rejected
at compile time — gate those on `access` instead). See
[Permissions](/define/permissions).

### FK-traversal arrows (`authz.arrows`) — M2

An **arrow** inherits authority across a foreign key: *"you can see this row
because you administer the organization it belongs to"*, or *"because you
control an ancestor in the tree."* Declare an FK hop, reference it from a
permission as `{ arrowRef, permission }`, and it lowers to SQL.

```typescript
authz: {
  arrows: {
    eventOrg:   { from: 'event',  fk: 'organizationId', to: 'organization' },
    folderTree: { from: 'folder', fk: 'parentId', to: 'folder', recursive: true },
  },
  permissions: {
    'event:view':  { anyOf: ['organizerOf', { arrowRef: 'eventOrg', permission: 'org:admin' }] },
    'folder:read': { anyOf: [{ arrowRef: 'folderTree', permission: 'org:admin' }] },
  },
}
```

A single hop lowers to an FK-hop subquery; a self-referential hop
(`folder.parentId -> folder`) lowers to a **bounded `WITH RECURSIVE` CTE**
(default depth 8, override per-arrow with `maxDepth` or per-permission with
`permissionMaxDepth`). The seed and **every walked row** are re-constrained to
the caller's active-org claim, an absent claim fails closed (`1 = 0`), and the
identifiers are compile-time constants while the claim binds as a parameter —
so an arrow is never an injection surface. An `unbounded: true` arrow with no
claim path is a hard compile error. Valid on both D1 and Neon. See
[Arrows](/define/arrows).

### Compile-time authz analyzer + diagnostics — M4

The compiler now projects your whole authorization model into a single policy
IR and runs static checks over it, reporting on the existing
**`[quickback:authz]`** console channel with stable, greppable codes:

- `AUTHZ_EMPTY_PERMISSION` — a permission that can never be satisfied (e.g. `allOf(a, not(a))`).
- `AUTHZ_SHADOWED_ARM` — an `anyOf` arm subsumed by a broader sibling.
- `AUTHZ_UNREACHABLE_ROLE` / `AUTHZ_UNREACHABLE_VIEW` — dead scope config.
- `AUTHZ_NOT_OVER_RESOURCE` — a `not` over a SQL site (the null-trap).
- `AUTHZ_PUSHDOWN_COST` — an `info` report of each permission's subquery count and recursive-CTE depth.
- `AUTHZ_CAPABILITY_CEILING` — **error** — a resource grants a `scope:<kind>:<role>` read beyond its `grants` profile.

Checks are **warn-by-default, compile-continues** (so a new check never breaks
an existing build on upgrade); set **`QUICKBACK_AUTHZ_STRICT=true`** to promote
every warning to a hard error. The analyzer is compile-time only — a defect in
a check downgrades to a single warning in the default path and can never take
down an otherwise-valid compile. See
[Diagnostics](/define/diagnostics).

### Aggregate-only views — M0.1

A view can return a rollup and **nothing else** — no `data[]`, no per-row field
selection — via `aggregateOnly: true`, with an optional `kMin` k-anonymity floor
that suppresses the aggregate below a threshold. This is what lets an F&B vendor
read dietary **counts** for an event with zero attendee rows. See
[Views](/define/views).

### Scope leftovers — M0.3

The relationship-scope surface gains three pieces:

- **Namespace scope-claim refresh** — a namespace gating `/event/:eventId/*` on a single conferring relationship lane refreshes `ctx.scope.<kind>` in memory for the request, so an attendee hitting an action under the prefix gets the scoped role stamped without a separate `/scope/v1/enter` round-trip.
- **`q.scope()` autofill** — `q.scope('event')` auto-stamps a column from the verified scope claim (`ctx.scope.event.id`) on insert, the same way `q.scope('organization')` stamps the tenant column. Insert-only and null-safe; it adds no read-time `WHERE`.
- **Set-valued sub-keys** — a `subKeys` entry ending in `[]` (e.g. `'shuttleId[]'`) projects **every** matched relationship row's value into the claim as a `string[]`, and the matching firewall arm is rewritten to an `inArray` row-slice (a driver covering multiple buses). An absent claim lowers to `inArray(col, [])` — a clean deny.

See [Scopes & capability grants](/define/scopes).

### Namespace scope-minting (`mintScope`) — §5.3

A namespace can now MINT a persisted scope JWT from its resolver. Set
**`mintScope: true`** and, on a successful relationship probe, the resolver
returns a scope token via `set-auth-token` — so a native client hitting
`/event/:eventId/*` carries the scoped role forward **without** calling
`/scope/v1/enter`.

```typescript
authz: {
  namespaces: {
    eventScope: { prefix: '/event/:eventId', via: ['attendeeOf'], mintScope: true },
  },
}
```

The mint fires **only** when `via:` confers exactly one scope role
unambiguously (a single conferring relationship lane); a multi-lane,
bulk-grant, or asymmetric `via:` **suppresses** the mint (fail-closed — those
callers use `/scope/v1/enter`). The token reuses the shared mint helper (TTL
clamped to ≤180s, sibling scope kinds carried forward) and the mint is
**best-effort** — a mint failure never fails the request, since the in-memory
stamp already authorized it. Opt-in per namespace; defaults to `false`. See
[Scopes — `mintScope`](/define/scopes#mintscope-mint-the-scope-token-from-the-namespace-resolver).

> **Gotcha:** a namespace emits its resolver — and thus the refresh and the
> mint — **only** when at least one *standalone* action's `path:` falls under
> the prefix. A namespace containing only record-based actions (which mount
> under their resource's CRUD routes, not the prefix) is **inert**. Add a
> standalone action like `eventHome` at `/event/:eventId/home`. See
> [Scopes — the resolver gotcha](/define/scopes#gotcha-a-namespace-only-emits-its-resolver-when-it-claims-a-standalone-action).

## v0.36.3 — May 25, 2026

### Fix: `/auth/v1/*` handler forwards `executionCtx` so OTP emails actually send

The generated `createAuth(env, cf?, executionCtx?)` schedules the fire-and-forget
verification-OTP send through `executionCtx.waitUntil(...)` — Better Auth advises
**not** awaiting the send so its latency doesn't leak into auth response timing.
That only works when `executionCtx` is passed. The composed Cloudflare worker
index emitted a **bare** `createAuth(c.env)` at the `app.all('/auth/v1/*')`
handler, so on Cloudflare the unawaited SES/SNS send was cancelled the moment the
handler returned — `send-verification-otp` returned 200 but no email went out.

The handler now threads `createAuth(c.env, c.req.raw.cf as any, c.executionCtx)`,
matching the action call-sites (`record-based`, `standalone`, `core/helpers`) and
the `getSession` call now passes the `cf` binding. (The earlier waitUntil fix had
only patched the legacy `providers/runtime` index emitter, not this composed one —
the two had drifted.) Recompile + redeploy to pick it up.

## v0.36.2 — May 25, 2026

Two firewall fixes uncovered in a relationship-scoped (event-attendee) project:
scoped reads stopped hiding rows behind a misclassified soft-delete column, and
the list route stopped rejecting org-optional callers before the firewall ran.

### Fix: a firewall `isNull` predicate on a non-`deletedAt` column no longer globalizes as soft-delete

The scoped `db` wrapper used by actions enforces soft-delete by injecting
`isNull(<col>)` into reads. It built its list of soft-delete columns by treating
**any** firewall `{ field, isNull: true }` predicate as a project-wide soft-delete
marker — so an `isNull` predicate on a foreign-key column (e.g.
`{ field: 'agendaItemId', isNull: true }`) was broadcast to **every** table that
merely had an `agendaItemId` column, rewriting scoped reads to
`agenda_item_id IS NULL`. Real rows (non-null FK) became invisible, so an action
that read-before-write saw "no row," attempted an insert, and 500'd on the
`(agenda_item_id, guest_id)` unique constraint.

Only `deletedAt` — the column the compiler injects into every table — now enters
the project-global soft-delete list. A custom `isNull` predicate is still enforced
**per-table** by that table's firewall (route layer); it is simply no longer
broadcast across the whole schema. This mirrors the v0.28 fix that made custom
owner columns per-table rather than global.

### Fix: list route no longer hard-requires an org when the firewall makes org optional

The generated CRUD **list** route emits an `ORG_REQUIRED` 400 (with a sysadmin
escape) before the firewall runs. It fired whenever an org scope appeared
**anywhere** in the firewall — including when org is just one arm of an `any:`
group. So after the relationship-scope migration made a firewall org-optional
(`any: [ { organizationId = ctx.activeOrgId }, { …via relationship } ]`), a
plain-JWT caller with no `activeOrgId` (e.g. an event attendee scoped by
relationship) got an instant 400 — the org-optional firewall that would have
scoped them correctly never even ran.

The gate now keys off org **mandatoriness** (`firewallRequiresOrg`): it fires
only when an org claim is genuinely required — a top-level (implicit-AND) or
`all`-nested org predicate. When org is one arm of an `any:` group, the list
route skips the 400 and lets the null-safe firewall scope the caller: the org
arm drops when `activeOrgId` is absent, sibling arms (relationship/owner) bind,
and `anyGuarded` denies (`1 = 0`) when every arm drops — so a no-org caller sees
exactly what the declared policy grants, never an unbounded cross-tenant read.
`?organizationId=` cross-tenant addressing and the sysadmin escape are unchanged,
and a plain org-scoped resource (no `any:` group) keeps the `ORG_REQUIRED` gate
byte-for-byte. Write paths (create / put / batch) still require an org so new
rows are always stamped with a tenant.

## v0.36.1 — May 25, 2026

Relationship-derived **scoped roles** — request-scoped authorization minted from
a verified relationship into a short-lived JWT.

### `authz.scopes` — scoped roles via a relationship probe

A new `authz.scopes` block turns a declared relationship into a request-scoped
role. Key the scope by a request field and back each role with a relationship:

```ts
authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'userId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
    },
  },
  scopes: {
    event: {
      requestField: 'eventId',
      roles: { attendee: { via: 'attendeeOf' } }, // shorthand: attendee: 'attendeeOf'
    },
  },
}
```

The compiler emits a `/scope/v1/enter` route that probes the relationship for the
caller ("is this user a confirmed guest of `eventId`?") and, on success, mints a
short-lived JWT carrying the verified scope. The auth middleware reads that claim
into `ctx.scope.<kind>` (e.g. `ctx.scope.event`).

Scoped roles then work everywhere roles do — `read.access`, view access,
`masking.show`, action access — as `scope:<kind>:<role>`, and the firewall can
scope rows by the verified id:

```ts
read: { access: { roles: ['admin', 'member', 'scope:event:attendee'] } },
masking: { email: { type: 'email', show: { roles: ['admin', 'scope:event:organizer'] } } },
firewall: { any: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'eventId', equals: 'ctx.scope.event' },
] },
```

So an attendee who "enters" an event reads that event's guest list — the `eventId`
firewall arm matches their verified scope — with no org-membership role, and
`email` stays masked unless they hold `scope:event:organizer`.

### Scoped-role name sugar

Write the bare role name and the compiler lowers it to canonical
`scope:<kind>:<role>` when it resolves uniquely:

```ts
access: { roles: ['attendee'] } // → scope:event:attendee
```

Reserved names (`admin`, `member`, `owner`), pseudo-roles, and anything declared
in `authz.roles` are never rewritten. A bare name that's ambiguous across scopes
is a compile error pointing you to the explicit `scope:<kind>:<role>` form.

## v0.36.0 — May 25, 2026

Project-wide `id` generation now applies to every write path, action `db` is
typed on Cloudflare D1, and realtime rooms can be gated on a unique non-PK column.

### `id` default moves to the column (`$defaultFn`)

The configured `generateId` strategy now lands on the `id` column itself —
`id: text("id").primaryKey().$defaultFn(() => createId())` — not just inside the
auto-CRUD route handler. Previously a table with an explicit `text("id").primaryKey()`
kept a bare PK, so a hand-written action doing `db.insert(...).values({…})` got no
`id` and failed at runtime with a `NOT NULL` 500 — invisible to `tsc` because the
scoped `db` was `any`. The injector (which already did this for tables with no
declared PK) now also appends the strategy's `$defaultFn` to an explicit `id` PK.
`generateId: false` (client-supplied ids) and `serial`/integer PKs are left
untouched; the rewrite is idempotent. `$inferInsert` now marks `id` optional.

### Action `db` is typed on Cloudflare D1

The scoped `db` passed to custom actions is no longer `any`. On D1 projects the
per-feature `define-action` helper types it as a Drizzle client whose
`.insert().values()` makes the org/owner/team scope columns optional (the runtime
fills them from context), while every other column you declared stays required —
so `tsc` catches a forgotten column, typos, and wrong types, and query results
come back typed. Neon/Postgres keep `db: any` for now.

`ctx.userId` is also narrowed to `string` (not `string | undefined`) in actions
whose `access` requires authentication — so a trusted write like
`db.insert(t).values({ ownerId: ctx.userId })` type-checks without a cast.
Actions reachable anonymously (a `PUBLIC` arm, including inside `or`/`and`) keep
`ctx.userId` optional.

### `access.resource` gains an optional `key`

The per-room authorization gate (`bindings.durableObjects[].access`) always
matched the WebSocket roomId against the backing table's primary key —
`eq(table.id, rowId)`. Rooms addressed by a different unique column had no way
to use it: a Yjs collaborative editor keyed by `event_pages.yjsRoomId`
(`ns.idFromName(row.yjsRoomId)`) would never match on `id`, so projects dropped
the `access` block and hand-rolled enforcement in `onConnect` — or, more often,
shipped no row-level check at all, leaving the room reachable by any
authenticated user who could guess a room id.

The long form now accepts `key`:

```ts
access: { resource: { feature: 'event-pages', table: 'eventPages', key: 'yjsRoomId' } }
```

The gate emits `eq(table.<key>, rowId)` against the named column. `key` defaults
to `'id'`, so every existing pin is byte-for-byte unchanged, and it also works
inside each `resources[kind].resource` of the multi-resource form. The compiler
validates the column exists against the pinned table's schema at compile time.

The "multi-table feature is ambiguous" compile error now points at all three
escapes — the long-form pin, the `key` option for non-id rooms, and declaring
the table's firewall with a `via:` relationship so membership gating runs in the
gate with no hand-written `onConnect`. See
[PartyServer rooms → per-room authorization](/platform/realtime/partyserver-rooms).

## v0.35.0 — May 25, 2026

New **FGA pillar** — OpenFGA / Google Zanzibar Relationship-Based Access
Control (ReBAC), compiled into your Worker. Plus a public-sharing surface
(type-bound public access + signed share links) built on it.

### Relationship-based access control (`authz.fga`)

Role-based access and one-hop relationship joins can't express *graph-shaped*
permissions: an `editor` who is automatically a `viewer`, a document that
inherits viewers from its folder, a `team#member` group grant, or a per-record
assignment that doesn't require a global role. The new `authz.fga` block adds a
true ReBAC engine modeled on [OpenFGA](https://openfga.dev):

- **Model** — declare object types, relations, and userset rewrites in the
  OpenFGA modeling DSL (`or` / `and` / `but not`, `[user:*]` public access,
  `[team#member]` usersets, `viewer from parent` tuple-to-userset). The compiler
  parses and semantically validates it at compile time — dangling type or
  relation references fail the compile loudly.
- **Tuples** — stored in a compiler-owned, organization-scoped `fga_tuples`
  table the compiler emits and migrates. A Check never reads another tenant's
  tuples.
- **Check engine** — resolved entirely inside the Worker (no external auth
  service): direct tuples, type-bound public access, userset subjects, computed
  usersets, tuple-to-userset, request-scoped contextual tuples, with cycle
  detection and a depth cap.
- **`ctx.fga`** — `check` / `listObjects` / `expand` / `write` / `read`,
  org-scoped, available in every action and handler.
- **`access: { fga: { relation, object } }`** — a new declarative access arm.
  The compiler fetches the record and emits an inline, fail-closed
  `ctx.fga.check('user:' + ctx.userId, relation, object)` gate (`object` is a
  `job:{id}` template over the record's columns).
- **Collection-level FGA filtering** — an `fga` arm on `read.access` (or a named
  view) now filters the collection `GET /`, not just `GET /:id`. The compiler
  resolves the caller's authorized object ids via the engine's `ListObjects` and
  injects an `inArray(id, …)` into the list query, so pagination, `total`, and
  `hasMore` stay correct. Supported shapes are bare `{ fga }`,
  `or:[{ roles }, { fga }]`, and `and:[{ roles }, { fga }]`; deeper nesting is a
  compile error. The authorized set is bounded by `authz.fga.listObjects.maxIds`
  (default 1000) — past the cap the request fails with a hard 422
  (`FGA_LIST_TOO_LARGE`) pointing to `POST /fga/v1/list-objects` for paginated
  enumeration.
- **REST API** — an OpenFGA-compatible surface at `/api/v1/fga/v1`
  (`check`, `list-objects`, `expand`, `read`, `write`), tenant-scoped; writes
  gated by `authz.fga.writeRoles` (default `['admin']`).

### Public sharing

`authz.fga.sharing` turns the FGA engine into a public-sharing mechanism:

- **Type-bound public access** — a model relation listing `user:*` becomes
  shareable-to-everyone by writing one tuple.
- **Signed share links** — `POST /api/v1/fga/v1/share` mints an HMAC-signed,
  opaque token for one `(object, relation)` grant; `GET /share/v1/:token` is a
  **public, unauthenticated** route that verifies the token and returns a
  configured public projection of the object (it leaks nothing beyond that
  object + relation).
- **Share URL** — both a dedicated route (`/share/v1/:token`, always mounted)
  and an optional vanity base URL (`share.<your-domain>`, added to your runtime
  routes) are supported.

### q-recruit showcase

The recruitment example now models `organization → job → application/interview`
with FGA: a `recruiter` is automatically a `viewer` and `can_manage` of a job,
applications inherit viewers from their job, and a job can be made publicly
shareable. New `assign-recruiter` and `make-public` actions demonstrate
`ctx.fga.write`, and `jobs.update` gates on `{ or: [{ roles }, { fga }] }`.

## v0.34.1 — May 24, 2026

Patch fixing a silent-skip bug in drizzle migration emission, plus a new
CLI subcommand for inspecting and repairing migration-state drift.

### Compiler now fails loud on drizzle migration state drift

When `quickback/drizzle/<dialect>/meta/_journal.json` fell out of sync
with the on-disk `.sql` files — typically because someone hand-rolled a
migration without a journal bump, or because a prior compile collided
with an existing tag — drizzle-kit's diff base became stale and it
silently skipped emission. The compile reported success, no new `.sql`
was written for newly defined tables, and the next `wrangler deploy`
shipped a Worker that 500'd on every request touching those tables.

The compiler now runs a precheck before any drizzle-kit invocation and
refuses to compile when it detects any of:

- `.sql` files on disk with no matching entry in `_journal.json`
- journal entries that reference missing `.sql` files
- non-sequential `idx` values in `_journal.json`
- two `.sql` files sharing the same `idx`
- (for `auth/` and `features/` only) journal entries with no matching
  `<idx>_snapshot.json` — drizzle-kit needs the snapshot as a diff base

Snapshot/journal drift that the previous `console.warn` only logged
server-side is now propagated to the CLI and printed under
"Compilation warnings".

### New `quickback migrations doctor` CLI command

Two modes:

```bash
quickback migrations doctor                    # diagnose
quickback migrations doctor --rebuild-journal  # repair
```

Diagnose walks `quickback/drizzle/{auth,features,files,webhooks,audit}/`,
prints a per-dir table (`.sql files`, `journal entries`, `snapshots`),
and flags every drift kind the compiler precheck enforces. Repair
rewrites `_journal.json` from on-disk `.sql` filenames in sorted order —
this is the recovery recipe users were applying by hand. Snapshot drift
on drizzle-kit dirs (`auth/`, `features/`) can only be repaired by
running `drizzle-kit introspect` against the live DB; the doctor prints
the exact command.

### Upgrade path for projects in the broken state

Projects with existing drift will get a clear error message and an
explicit fix command on the next compile against v0.34.1:

```
✖ Drizzle migration state is inconsistent — refusing to compile.
  Causes:
    - quickback/drizzle/features: 7 .sql file(s) have no matching journal entry: ...
  Fixes:
    - Run `quickback migrations doctor` to diagnose, then
      `quickback migrations doctor --rebuild-journal` to repair.
```

No action needed for projects whose journals are already in sync.

## v0.34.0 — May 23, 2026

Customizable email-OTP templates and a dedicated admin-create-user hook
— closes a longstanding gap where projects on `email.provider: 'aws-ses'`
had to live with the generic "Your password reset code is ..." copy for
admin-bootstrapped accounts.

### Per-type email-OTP template overrides

Drop a `.ts` file at `quickback/email-templates/<name>.ts` that
default-exports `(input) => ({ subject, html, text })`, then reference
it from the new `plugins.emailOtp.templates` map. Missing keys fall
through to the package defaults from `@quickback-dev/better-auth-aws-ses`
— this is purely additive; existing `emailOtp: true` projects emit the
same code they did before.

```ts title="quickback/quickback.config.ts"
defineAuth("better-auth", {
  plugins: {
    emailOtp: {
      templates: {
        'forget-password': './email-templates/forget-password',
        'welcome':         './email-templates/welcome',
      },
    },
  },
});
```

```ts title="quickback/email-templates/welcome.ts"
export default function welcome({ otp, name, appName, appUrl }) {
  const greeting = name ? `Hi ${name},` : 'Hi,';
  return {
    subject: `Welcome to ${appName} — set your password`,
    html:    `<h1>Welcome to ${appName}</h1><p>${greeting} use this code: <b>${otp}</b></p>`,
    text:    `${greeting}\n\nYour ${appName} code: ${otp}`,
  };
}
```

Recognized keys: `'sign-in' | 'email-verification' | 'forget-password' |
'welcome'`. The first three map 1:1 to Better Auth's emailOTP `type`
enum.

### Synthetic `welcome` routing — credential-row lookup

`'welcome'` has no native Better Auth OTP type. When configured, the
generated `sendVerificationOTP` callback queries the `account` table
for a `credential`-provider row for the recipient *before* rendering;
if the user has none (typical of an admin-plugin-created account
without a password), the send is rerouted through the welcome
template. Without a welcome template, those sends fall through to
`forget-password` — same behavior as before. DB-error path fails open
to the normal forget-password flow so a transient failure can't
deadlock the password-reset surface.

Provider scope: overrides are only honored on `email.provider:
'aws-ses'`. The Cloudflare Email path uses the helper's own template
pipeline and ignores the new config.

### New recognized hook: `after-admin-user-create.ts`

A virtual event that wires into the same Better Auth
`databaseHooks.user.create.after` slot as the generic
`after-user-create.ts`, but the generator gates the call on
`_baCtx?.context?.path` matching `/admin/create-user` so it only fires
when the row came from the admin plugin's create-user endpoint. Use it
for first-time-user provisioning (welcome email, default org
assignment, audit-log writes) without inspecting the BA context by
hand. The generic `after-user-create.ts` still runs for every signup,
including admin-created ones; the two coexist in a single merged
`after:` handler.

```
quickback/hooks/
  after-user-create.ts        # every signup
  after-admin-user-create.ts  # admin-plugin /admin/create-user only
```

### File transport mirrors existing slots

The new `quickback/email-templates/` directory follows the same
pattern as `quickback/plugins/`, `quickback/hooks/`, and
`quickback/lib/`: the CLI walks the folder and uploads each `.ts` file
in a new `projectEmailTemplates` field on the compile request; the
better-auth provider stages referenced files into
`src/email-templates/<name>.ts` and emits default imports in
`src/lib/auth.ts`. Files not referenced by any template entry are
ignored — the CLI uploads the whole directory; the compiler picks only
what's needed. A relative path pointing at a file the CLI never
shipped fails compile fast with a "place the file at … and rerun"
hint.

See [AWS SES Plugin → Per-Type Template Overrides](/platform/auth/plugins/aws-ses#per-type-template-overrides)
and [Account UI → Hooks](/ui/account/hooks#recognized-events).

## v0.33.6 — May 22, 2026

Two compiler-side fixes for the queue-consumer / embedding surface and one
schema-emission bug that blocked `generateId: 'cuid'` projects.

### Schema-emitted `createId()` now ships with a polyfill (no missing import)

When `providers.database.config.generateId` is set to `'cuid'`, the audit-
field injector emits an id column like
`id: text('id').primaryKey().$defaultFn(() => createId())`. Previously the
schema source referenced `createId` but no import landed, so `tsc` on the
generated project failed with `Cannot find name 'createId'`. Recurring
bug — every recompile re-emitted the broken file (the "do not edit"
banner doesn't protect from regeneration).

Fix: the injector now prepends a top-of-file polyfill backed by
`crypto.randomUUID()` — a Workers / Node 19+ / Bun global — instead of
trying to import `@paralleldrive/cuid2`. The package isn't bundled by
Quickback (per the [hooks doc](/ui/account/hooks)'s "stick to
built-ins" guidance) and the schema-gen sandbox can't resolve project
node_modules anyway, so the polyfill is the only path that works
out-of-the-box.

```ts title="Generated schema header (cuid strategy)"
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';

// Polyfill — Quickback compiler does not ship @paralleldrive/cuid2 in
// generated projects, and the schema-gen sandbox can't see node_modules.
// crypto.randomUUID() is a Workers / Node 19+ / Bun global.
const createId = () => crypto.randomUUID();

export const widgets = sqliteTable('widgets', {
  id: text('id').primaryKey().$defaultFn(() => createId()),
  // ...
});
```

If you want real cuid2 strings (collision-resistant, shorter than UUIDs)
you can still install `@paralleldrive/cuid2` and add the import yourself —
the injector honours an existing import and skips the polyfill.

### queue-consumer.ts header reflects what the file actually does

Before v0.33.6 the generated `src/queue-consumer.ts` always carried the
preamble:

```
* Queue Consumer for Embedding Jobs & Custom Queue Handlers
*
* Processes embedding jobs asynchronously using Workers AI and Vectorize.
* Also supports custom queue handlers defined in services/queues/.
```

— even on projects with zero embedding features. Users on
custom-queue-only deployments would search for `EMBEDDINGS_QUEUE` /
Vectorize bindings that wrangler.toml correctly didn't emit, wasting
diagnostic time.

The preamble now reflects the actual surface:

| `hasEmbeddings` | `hasCustomHandlers` | Header |
|---|---|---|
| ✓ | ✓ | "Queue Consumer for Embedding Jobs & Custom Queue Handlers" |
| ✓ | — | "Queue Consumer for Embedding Jobs" |
| — | ✓ | "Queue Consumer for Custom Queue Handlers" + explicit "No embedding bindings (EMBEDDINGS_QUEUE / Vectorize / AI) are referenced" |
| — | — | "Queue Consumer Stub" (additionalQueues with no handler yet) |

### Symmetric embedding detection prevents silent drift

The wrangler.toml emit path (`EMBEDDINGS_QUEUE` producer/consumer
declaration) and the queue-consumer.ts emit path (embedding handler code)
previously used two different functions to detect "this project has
embeddings": `hasEmbeddingsConfig` checked both `feature.resource.
embeddings` and `feature.tables[].resource.embeddings`;
`extractEmbeddingFeatures` only iterated the tables array. A feature
shape with feature-level embeddings sugar but no populated tables would
land in the wrangler binding while NOT landing in the consumer file
— silent drift.

`hasEmbeddingsConfig` is now defined as
`extractEmbeddingFeatures(...).length > 0`. The two stay in lock-step
by construction. `extractEmbeddingFeatures` now reads from both shapes
(tables-level when present, feature-level otherwise).

## v0.33.5 — May 22, 2026

Two structural fixes that resolve recurring classes of bug for projects with
integer primary keys and for projects whose audit columns drift between
schema and database.

### URL `:id` params coerce to Number when the PK column is integer

Per-id CRUD handlers (`GET /:id`, `PATCH /:id`, `DELETE /:id`, `PUT /:id`)
extracted the URL param as a string and passed it straight into
`eq(table.id, id)`. For resources with `integer().primaryKey()` columns —
triggered either by `generateId: 'serial'` on the database config or by
imported legacy schemas — every per-id route raised a downstream tsc
type error (integer ≠ string). One production project reported 249 such
errors across 30+ generated `*.routes.ts` files.

Generated handlers for integer-PK resources now emit:

```ts
const __idRaw = c.req.param('id');
const id = Number(__idRaw);
if (!Number.isInteger(id)) {
  return c.json(ValidationErrors.invalidInput([{
    path: ['id'],
    message: `Expected integer, got "${__idRaw}"`,
    code: 'invalid_type',
  }]), 400);
}
```

Text-PK resources (the default and the recommended path) are unchanged.

A soft-deprecation warning now fires at compile time when
`providers.database.config.generateId === 'serial'` is set, nudging new
projects toward text-PK strategies (`'cuid'`, `'uuid'`, `'prefixed'`, or
the default). Existing serial-PK projects keep working.

### Audit-field injection now runs for every dialect

The compiler auto-injects `created_at`, `modified_at`, `created_by`, and
`modified_by` columns into every feature table, and the scoped-db
runtime auto-fills them on insert. Until v0.33.5, the early injection
step in `parseCompilerInput` was gated on `targetDialect ===
'postgresql'`. SQLite/D1 projects fell back to a later injection inside
`generateFeature`, which only fired when `feature.tables.length > 0` —
features arriving without a populated tables array (raw-drizzle shapes,
certain legacy migration tables) silently skipped audit injection.

Symptom: schemas claimed the columns existed (so the scoped-db proxy
tried to fill them on insert), but the database table didn't have them.
Inserts failed with "column doesn't exist." Users reported this for the
`user_preferences` table and, earlier, for `chat_reads` (the
`0028_chat_reads_id_column.sql` incident).

The early injection now runs for all dialects. `injectAuditFields` is
already idempotent, so the subsequent per-target injections in
`compilePureSQLTarget` and `feature-generator.ts` are no-ops when the
columns are already present. Net: every feature's schema source has
audit columns by the time drizzle-kit reads it, regardless of dialect
or feature shape.

Upgrade path: recompile against v0.33.5, commit the generated migration
that adds the missing audit columns, then `wrangler d1 migrations apply`.

## v0.33.4 — May 21, 2026

Patch fixes covering upgrade-path bugs and OpenAPI spec/route divergence. No
new features or breaking changes.

### OpenAPI emits CRUD endpoints only when the route emitter registers them

The OpenAPI emitter previously advertised `GET /`, `POST /`, `GET /:id`,
`PATCH /:id`, `DELETE /:id`, and the corresponding `/batch` operations on
every resource, regardless of which CRUD verbs were actually declared.
Clients generated from `openapi.json` then issued requests that fell
through to `ROUTE_NOT_FOUND` from the firewall layer — a silent contract
break for anyone whose code path was driven by openapi-typescript or
similar codegen.

Each gate now mirrors the route emitter exactly. A resource that only
declares `read + create + delete` (no `update`) gets no PATCH operations
in the spec. A write-only sink (`create` only, no `read`) gets no GET
operations. Same for batch variants — `POST /batch` only when
`crud.create` is set, `DELETE /batch` only when `crud.delete` is set,
etc.

If your generated client was calling these spurious endpoints, those
calls were already failing at runtime; this just removes them from the
spec.

### Legacy D1 audit-timestamp migrations rewritten in place

Compilers ≤ v0.33.1 emitted
`DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))` for audit columns
(`created_at`, `modified_at`, etc.). Cloudflare D1 rejects this in
`ALTER TABLE ADD COLUMN` with `SQLITE_ERROR [code: 7500] "Cannot add a
column with non-constant default"`. v0.33.2 stopped emitting the
non-constant form, but pending unapplied legacy migration files on
upgrading projects still poisoned `wrangler d1 migrations apply` and
left the deploy stuck.

The D1 migration safety pass now retrofits these files in place,
replacing the strftime expression with the same `'1970-01-01T00:00:00.000Z'`
constant v0.33.2+ uses. The compile-time warning lists the affected
columns:

```
⚠ D1 helper adjusted features migrations for Cloudflare D1 safety:
  0004_early_dracula.sql [rewrote=applied_at,created_at,modified_at]
```

Upgrade path: recompile against v0.33.4, commit the rewritten SQL, then
`wrangler d1 migrations apply` proceeds.

### D1 helper warning is now actionable

The same migration safety pass auto-prepends `PRAGMA defer_foreign_keys
= on` to every drizzle table-rebuild migration — required on D1
because it doesn't honour `PRAGMA foreign_keys=OFF/ON` mid-migration the
way SQLite does. The pass used to surface this insertion as a warning
on every recompile, training users to ignore the warning entirely.

The PRAGMA is still inserted (it has to be), but no longer fires the
warning. Stripped Better Auth cleanup, reordered child-first DROPs, and
the new `rewrote=…` annotation above still warn — those are cases where
a human should look.

### Reference: `account.appUrl` / `account.tenantPattern`

v0.33's redesigned dashboard org-card uses two new optional fields on
the `account` config to deep-link into the tenant's own app:

```ts
// quickback.config.ts
account: {
  // …existing fields
  appUrl:        'https://app.example.com',
  tenantPattern: '/{slug}',
}
```

Both are typed in the config schema and baked into the Account SPA at
build time as `VITE_QUICKBACK_APP_URL` and `VITE_TENANT_URL_PATTERN`.
Without them, the dashboard falls back to a single-target card that
links to the account org-settings page (legacy v0.32 behaviour). Adding
them enables the two-zone layout with the external deep-link affordance.

(No code change in v0.33.4 — the fields shipped with v0.33.0 but weren't
called out in the changelog.)

## v0.33.0 — May 21, 2026

### Dedicated hostnames for `/auth/v1/*` and `/api/v1/*` (Shape 1)

Two new optional overrides let you carve specific backend surfaces onto dedicated hostnames, served by the same Worker:

```ts
// quickback.config.ts
auth: { domain: 'auth.example.com' },  // /auth/v1/* lives here too
api:  { domain: 'api.example.com'  },  // /api/v1/*  lives here too
```

On the dedicated hostname, the hostname IS the namespace — `/v1/*` is a shortcut that the Worker-entry rewriter prepends with `/auth` or `/api` before route matching. So `https://api.example.com/v1/users` reaches the same handler as `https://quickback.example.com/api/v1/users`. Both forms work; the long form is also accepted on the dedicated host. Non-matching paths return a JSON 404 ("Not found on api.domain — this hostname serves /api/v1/* and the /v1/* shortcut only") — each hostname stays dedicated to its surface. `/health` and `/openapi.json` are explicit exceptions because monitoring/discovery agents expect them everywhere.

Both new domains:
- Get a `{ pattern, custom_domain: true }` route in `wrangler.toml`
- Join `trustedOrigins` (CORS allowlist)
- Join the cross-subdomain cookie inference (auto-enables `crossSubDomainCookies` when ≥2 same-parent subdomains are configured)
- Join CSP `connect-src` so SPAs can fetch from them

**Dual-reach:** all routes remain reachable at the unified backend host (see below) regardless of which dedicated hostnames are configured. The dedicated hostnames are aliases, never replacements.

### `quickback.{baseDomain}` codified as the always-present unified backend

The compiler has always auto-inferred a `quickback.{baseDomain}` hostname when any subdomain is configured. v0.33 promotes this from inferred fallback to **load-bearing contract**:

- The unified backend hostname is GUARANTEED to exist when any custom subdomain (`account.domain`, `cms.domain`, `auth.domain`, `api.domain`, `apps.<n>.domain`) is set.
- Every compiler-emitted backend route (api, auth, storage, health, openapi, realtime, mcp, bundled SPAs, sysadmin endpoints) is reachable at the unified host, always.
- Per-surface domain overrides are additive (Shape 1) — they create more hostnames, never remove routes from the unified host.

This is forward-pointing: the future management layer can rely on a single discovery URL pattern (`https://quickback.{baseDomain}/...`) to find any backend regardless of how the operator split their surfaces onto subdomains.

Inference also extends to consider `auth.domain` and `api.domain` as domain sources, so a project configuring only one of them still derives `quickback.{baseDomain}` automatically. Explicit `config.domain` still wins over inference.

### `compiler.routes` config block (scaffold — partial)

Optional path-prefix overrides for compiler-emitted system routes:

```ts
compiler: {
  routes: {
    storage:   '/files',          // default '/storage'
    broadcast: '/realtime',       // default '/broadcast/v1'
    mcp:       '/agent',          // default '/mcp'
    health:    '/_health',        // default '/health'
    openapi:   '/openapi.yaml',   // default '/openapi.json'
  },
}
```

**Status:** the config is validated and reaches the reserved-prefix registry (so SPA pass-through and compile-time collision detection already honor overrides), but the per-section route emitters still hardcode the defaults. Setting a non-default value today will produce a misconfigured Worker — the registry knows the new path but the actual `app.route('/storage/v1', …)` mount uses the old one. **Don't set these in production yet.** Per-section emit wiring lands in a follow-up.

`/api/v1/` and `/auth/v1/` are NOT configurable here — those are deep contracts touched by feature emitters, OpenAPI generation, generated clients, and BA's own basePath. The Shape 1 hostname overrides above are the supported way to relocate them.

## v0.32.0 — May 21, 2026

### Single-origin path-routed architecture

A modern Quickback project now runs on **one Cloudflare Worker behind one custom domain**, with the bundled Account and CMS SPAs path-mounted at `/account/` and `/cms/`, system routes at `/api/`, `/auth/`, `/storage/`, and the user's own SPAs anywhere else they want. No cross-subdomain cookies, zero CORS preflights, no separate `api.`/`account.`/`cms.` subdomains required:

```ts
// quickback.config.ts
apps: { www: { domain: 'app.example.com' } },  // user's root SPA
account: true,                                  // bundled at /account/
cms: true,                                      // bundled at /cms/
// /api/, /auth/, /storage/, /openapi.json, /health all live here too
```

The bug that previously broke this shape: when a hostname-mounted root SPA was configured (`apps.www.domain`), its middleware swallowed every path that wasn't in a hardcoded list (`/api/`, `/auth/`, `/admin/`, `/storage/`, `/health`). Requests to `/account/profile` got remapped to `/www/account/profile`, 404'd, then SPA-fallback'd to the wrong SPA. v0.32 introduces a **reserved-prefix registry** (`resolveReservedPrefixes` in `apps/compiler/src/generators/core/reserved-prefixes.ts`) as the single source of truth for what every catchall middleware must pass through:

- **System** (always present): `/api/`, `/auth/`, `/storage/`, `/health`, `/openapi.json`
- **Conditional** (only when feature is on): `/account/` (when `account: true`), `/cms/` (when `cms: true`), `/broadcast/v1/` (realtime), `/mcp` (MCP server)
- **User-contributed**: every `apps.<name>.path` for path-mounted apps

Path-mounted apps that collide with a system or bundled-SPA reservation throw a compile-time error naming both owners. `apps.api = { path: '/api' }` errors clearly instead of silently hijacking feature routes at runtime.

### Admin URL namespace migration: `/admin/v1/*` → `/auth/v1/admin/*`

Quickback's legacy admin tier emitted three things under `/admin/v1/*`:

1. A role-admit middleware that admitted both `'admin'` and `'sysadmin'` — contradicting the pseudo-role evaluator's strict-independence model (`ADMIN` admits `'admin'` only; `SYSADMIN` admits `'sysadmin'` only).
2. `GET /admin/v1/organizations` — a thin proxy to Better Auth's own `/auth/v1/organization/list`.
3. `GET /admin/v1/sysadmin/organizations` — cross-tenant org listing, sysadmin-only.

In v0.32, the first two are **removed** and the third **renames** to `GET /auth/v1/admin/list-organizations`. The new path sits under Better Auth's admin URL namespace (sibling of `/auth/v1/admin/list-users`, `/auth/v1/admin/ban-user`, etc.), follows BA's verb-noun convention, and keeps its sysadmin-only gate inside the handler — Quickback-emitted, not BA-mounted.

What this enables: `/admin/*` is no longer a reserved system prefix. Projects can mount their own admin SPA via `apps.admin = { path: '/admin' }` without colliding with anything.

What changes for callers:
- The bundled CMS SPA's sysadmin org switcher is updated in lockstep (no migration needed for projects using the SPA as-is).
- External callers of `/admin/v1/organizations` should migrate to BA's `/auth/v1/organization/list` (same behavior — caller's memberships).
- External callers of `/admin/v1/sysadmin/organizations` should migrate to `/auth/v1/admin/list-organizations`.
- Features that previously relied on the "admit both admin AND sysadmin" middleware should declare `access: { roles: ['ADMIN', 'SYSADMIN'] }` explicitly on each route.

The `ADMIN_ROUTE_FEATURES` carve-out in the compiler is also removed — feature routes always mount at `/api/v1/<feature>`, and admin-only behavior is enforced by per-operation `access: { roles: ['ADMIN'] }` at the resource level.

### Two compiler bug fixes

- **Soft-delete index callback parameter bug.** The auto-index injector emitted hardcoded `t.<col>` references regardless of the existing extras callback's parameter name. When a user (or upstream transform) wrote `(table3) => ({ … })`, the appended composite-index entries referenced `t.organizationId, t.deletedAt` — `t` undefined inside the arrow scope, `ReferenceError` at module load. Fixed in `apps/compiler/src/utils/auto-indexes.ts` by capturing and reusing the callback's parameter name.
- **Reserved-word action filename collision.** Action discovery walks `actions/*.ts` and emits `import <name> from './actions/<name>'`. A user filename of `import.ts`, `delete.ts`, `await.ts`, `export.ts`, etc. produced `import import from './actions/import';` — a SyntaxError on module load. Fixed in `apps/compiler/src/codegen/discovered-actions.ts` by aliasing reserved-word and invalid-identifier filenames to `__action_<sanitized>` bindings while preserving the public action key (route emitters use bracket notation, so reserved-word keys are fine in the lookup map).

