Changelog
Release notes and version history for the Quickback compiler, CLI, and platform.
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.tomlno 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-kitcan report "No schema changes" instead of regenerating duplicate0000_*.sqlfiles - 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:
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 samectx.<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 accessroles: ["admin"]for org-membership admin accessroles: ["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 admitsysadminwhere appropriate) - unsafe cross-tenant actions and tenant-bypass helpers require
sysadminonly
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:
id: text("id").primaryKey().$defaultFn(() => createId())while satisfying the reference via:
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.
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.
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.
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.
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.
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.
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.
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— ananyOfarm subsumed by a broader sibling.AUTHZ_UNREACHABLE_ROLE/AUTHZ_UNREACHABLE_VIEW— dead scope config.AUTHZ_NOT_OVER_RESOURCE— anotover a SQL site (the null-trap).AUTHZ_PUSHDOWN_COST— aninforeport of each permission's subquery count and recursive-CTE depth.AUTHZ_CAPABILITY_CEILING— error — a resource grants ascope:<kind>:<role>read beyond itsgrantsprofile.
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.
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.
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 refreshesctx.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/enterround-trip. q.scope()autofill —q.scope('event')auto-stamps a column from the verified scope claim (ctx.scope.event.id) on insert, the same wayq.scope('organization')stamps the tenant column. Insert-only and null-safe; it adds no read-timeWHERE.- Set-valued sub-keys — a
subKeysentry ending in[](e.g.'shuttleId[]') projects every matched relationship row's value into the claim as astring[], and the matching firewall arm is rewritten to aninArrayrow-slice (a driver covering multiple buses). An absent claim lowers toinArray(col, [])— a clean deny.
See Scopes & capability grants.
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.
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.
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 likeeventHomeat/event/:eventId/home. See Scopes — the resolver gotcha.
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:
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:
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:
access: { roles: ['attendee'] } // → scope:event:attendeeReserved 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:
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.
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:
- 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 parenttuple-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_tuplestable 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-closedctx.fga.check('user:' + ctx.userId, relation, object)gate (objectis ajob:{id}template over the record's columns).- Collection-level FGA filtering — an
fgaarm onread.access(or a named view) now filters the collectionGET /, not justGET /:id. The compiler resolves the caller's authorized object ids via the engine'sListObjectsand injects aninArray(id, …)into the list query, so pagination,total, andhasMorestay correct. Supported shapes are bare{ fga },or:[{ roles }, { fga }], andand:[{ roles }, { fga }]; deeper nesting is a compile error. The authorized set is bounded byauthz.fga.listObjects.maxIds(default 1000) — past the cap the request fails with a hard 422 (FGA_LIST_TOO_LARGE) pointing toPOST /fga/v1/list-objectsfor paginated enumeration. - REST API — an OpenFGA-compatible surface at
/api/v1/fga/v1(check,list-objects,expand,read,write), tenant-scoped; writes gated byauthz.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/sharemints an HMAC-signed, opaque token for one(object, relation)grant;GET /share/v1/:tokenis 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:
.sqlfiles on disk with no matching entry in_journal.json- journal entries that reference missing
.sqlfiles - non-sequential
idxvalues in_journal.json - two
.sqlfiles sharing the sameidx - (for
auth/andfeatures/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:
quickback migrations doctor # diagnose
quickback migrations doctor --rebuild-journal # repairDiagnose 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.
defineAuth("better-auth", {
plugins: {
emailOtp: {
templates: {
'forget-password': './email-templates/forget-password',
'welcome': './email-templates/welcome',
},
},
},
});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 onlyFile 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 and Account UI → Hooks.
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'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.
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:
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:
// 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:
// quickback.config.ts
auth: { domain: 'auth.example.com' }, // /auth/v1/* lives here too
api: { domain: 'api.example.com' }, // /api/v1/* lives here tooOn 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 inwrangler.toml - Join
trustedOrigins(CORS allowlist) - Join the cross-subdomain cookie inference (auto-enables
crossSubDomainCookieswhen ≥2 same-parent subdomains are configured) - Join CSP
connect-srcso 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:
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:
// 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 tooThe 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/(whenaccount: true),/cms/(whencms: true),/broadcast/v1/(realtime),/mcp(MCP server) - User-contributed: every
apps.<name>.pathfor 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/*:
- A role-admit middleware that admitted both
'admin'and'sysadmin'— contradicting the pseudo-role evaluator's strict-independence model (ADMINadmits'admin'only;SYSADMINadmits'sysadmin'only). GET /admin/v1/organizations— a thin proxy to Better Auth's own/auth/v1/organization/list.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/organizationsshould migrate to BA's/auth/v1/organization/list(same behavior — caller's memberships). - External callers of
/admin/v1/sysadmin/organizationsshould 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 referencedt.organizationId, t.deletedAt—tundefined inside the arrow scope,ReferenceErrorat module load. Fixed inapps/compiler/src/utils/auto-indexes.tsby capturing and reusing the callback's parameter name. - Reserved-word action filename collision. Action discovery walks
actions/*.tsand emitsimport <name> from './actions/<name>'. A user filename ofimport.ts,delete.ts,await.ts,export.ts, etc. producedimport import from './actions/import';— a SyntaxError on module load. Fixed inapps/compiler/src/codegen/discovered-actions.tsby 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).
v0.29.0 — May 19, 2026
Opt-in id auto-injection (compiler.injectId: 'auto')
Quickback's generated CRUD routes, FK extractor, relationship resolver, and /:id mount points all assume every feature table has an id primary key column. Authoring a table without one — common when porting an existing schema, or when a domain genuinely uses a compound or non-id PK — produced silent failures: routes that 404, batch operations that targeted the wrong rows, FK lookups that returned empty.
v0.29 adds an opt-in flag that auto-injects an id column into every feature table that doesn't already declare one. Same mechanism as the existing createdAt/modifiedAt injection — early-pass, idempotent, dialect-aware.
// quickback.config.ts
compiler: {
injectId: 'auto', // off by default; opt in per project
}When 'auto':
- Tables with no
.primaryKey()declared getid: text("id").primaryKey().$defaultFn(() => createId())(or the matching shape fordbConfig.generateId—serial,uuid,prefixed). - Tables that already declare an
idcolumn (in any shape) are no-op — the injector is idempotent. - Tables with a
.primaryKey()on a non-idcolumn raise a clear compile error that names the offending column and points at the v0.30 follow-up which will rewrite custom PKs to unique indexes automatically. v0.29 surfaces the conflict; v0.30 fixes it.
Default stays false so existing projects don't get migration churn. Opt in when you want to lean on every-table-has-id semantics across your schema.
Why this is opt-in (and what v0.30 changes)
The "every table has id" assumption is load-bearing across 68+ sites in the compiler — every CRUD emitter, the FK regex (.references(() => target.id)), the relationship resolver hydration block, the /:id route mounts. v0.29 only injects the column; v0.30 will close the remaining gaps:
- FK extraction migrates from
.idregex to schema-aware metadata lookup, so tables with non-idPKs participate in FK display + relationship resolution. - The PK→unique-index rewrite lands — tables with explicit non-
idPKs auto-keep their uniqueness as a constraint while gaining the auto-injectedid. - The flag flips to default-on; opting out becomes the rare path.
v0.29 ships the foundation so projects can opt in today without waiting for the full structural pass.
v0.28.0 — May 19, 2026
INTERNAL pseudo-role: server-only schemas, unforgeable from HTTP
v0.27 had five pseudo-roles — PUBLIC, AUTHENTICATED, USER, ADMIN, SYSADMIN — and one structural gap: there was no way to mark a schema as "the server enumerates this; no user-facing route should ever read across tenants." Push subscriptions, personal access tokens, OAuth tokens, broadcast job tables — anywhere the server needs full visibility but users should only see their own row. The workaround was firewall: { exception: true } plus an out-of-band convention, which left auth on the schema looking incidental.
v0.28 adds INTERNAL as the sixth pseudo-role. Schemas with roles: ['INTERNAL'] are reachable only by AppContext values where ctx.internal === true — a flag the HTTP auth middleware never sets. No session, JWT, header, query parameter, or body field populates it. The flag is reserved for compiler-emitted server-internal entry points (queue consumers, cron workers, server-side helpers that bypass HTTP entirely).
// features/comms/push-subscriptions.ts
export default defineTable(push_subscriptions, {
firewall: [{ field: 'ownerId', equals: 'ctx.userId' }],
// User reads their own subscriptions via the standard CRUD route.
read: { access: { roles: ['USER'] } },
// Server enumerates ALL subscriptions for a broadcast — INTERNAL only.
crud: {
delete: { access: { roles: ['INTERNAL'] } },
},
});The split-surface pattern is the recommended shape: a user-facing access node uses USER / AUTHENTICATED / a real role; a server-only access node uses INTERNAL. Mixing INTERNAL with a user-facing pseudo-role on the same access node is a compile error — it almost always means the developer is confused about which side of the trust boundary they're on. The error names the cohabiting markers explicitly and points at the fix (split into separate access blocks).
The runtime stamping (queue consumer ctx, cron worker ctx) lands in a follow-up; v0.28 ships the declarative surface so projects can mark schemas accurately today. Until the stamping ships, server-internal code uses unsafeDb to enumerate INTERNAL surfaces — the security guarantee is in place because no HTTP-derived context can ever carry ctx.internal === true.
Reserved-name fix: SYSADMIN and INTERNAL added to ALWAYS_RESERVED
SYSADMIN was previously omitted from the ALWAYS_RESERVED set in authz-schema.ts. The runtime validator rejected roles: ['SYSADMIN'] when cms.sysadmin: true wasn't set, but the shadow-declaration path — user code writing authz.roles.SYSADMIN: { roles: [...] } to silently change the pseudo-role's meaning — wasn't blocked. v0.28 closes that gap by adding both SYSADMIN and INTERNAL to the unconditionally-reserved set.
Per-table OWNER_SCOPE_RULES: explicit owner predicates no longer over-scope
When a user declared a non-canonical owner predicate on one table — e.g. firewall: [{ field: 'linkedUserId', equals: 'ctx.userId' }] — that column got promoted into the project-global OWNER_SCOPE_RULES list. Any other table in the project that happened to have a linkedUserId column (even as a plain FK reference, not for scoping) had the same WHERE linkedUserId = ctx.userId injected on every read, update, and delete. Silent over-scoping that surfaced only when a query returned the wrong rows.
v0.28 narrows the global list to the canonical owner column — the one matching authDefaults.owner.column (default ownerId). Explicit predicates on custom-named columns are honored on their declaring table via that table's own buildFirewallConditions helper, but stay per-table. Reads on other tables with the same column name are unaffected.
// Before v0.28: linkedUserId on `comments` ALSO got the WHERE
// because `event_organizers` declared an explicit owner predicate.
export default defineTable(event_organizers, {
firewall: [{ field: 'linkedUserId', equals: 'ctx.userId' }],
});
export default defineTable(comments, {
// comments.linkedUserId is just an FK reference; we don't want it scoped.
firewall: [{ field: 'eventId', equals: 'ctx.activeOrgId' }],
});In v0.28, event_organizers queries get the per-table linkedUserId = ctx.userId predicate as before; comments queries are no longer mis-scoped.
If you genuinely want the same column to scope multiple tables (rare), declare the explicit firewall predicate on each table. The implicit "every table with this column gets scoped" behavior was the bug — explicit-per-table is the cure.
v0.27.0 — May 19, 2026
Narrow ctx.<exposeAs> typing for namespace-scoped actions
Since v0.23 the namespace middleware has hydrated ctx.<exposeAs> with the loaded resource row before each action runs. The static type of that field stayed any — runtime safety was solid, but autocomplete and typo-catching were missed opportunities.
v0.27 ships the second half: one per-namespace defineAction helper emitted alongside the standard one. Importing from the narrowed helper retypes ctx.<exposeAs> inside execute({ ctx }) to the loaded row's $inferSelect shape. Nothing about discovery, routing, or runtime behavior changes — this is purely TypeScript ergonomics.
// Before — ctx.event is `any`
import { defineAction } from '../.quickback/define-action';
// After — ctx.event is `typeof events.$inferSelect`
import { defineAction } from '../.quickback/define-action.event-scope';
export default defineAction({
path: '/event/:eventId/feed-moments',
method: 'POST',
input: z.object({ body: z.string() }),
access: { roles: ['attendee'] },
async execute({ ctx, input }) {
ctx.event.id // ✓ string, autocomplete works
ctx.event.startsAt // ✓ Date
ctx.event.idd // ✗ TS2339 — typo caught at compile time
},
});Where the file lives
The compiler emits one narrowed helper per (feature, namespace) pair where the feature owns at least one action whose path: falls under the namespace prefix. Filename is define-action.<kebab(namespaceName)>.ts (eventScope → define-action.event-scope.ts), sibling to the standard define-action.ts:
src/features/feeds/.quickback/
├── define-action.ts # generic — standard fallback
└── define-action.event-scope.ts # ctx.event narrowedA feature contributing to multiple namespaces gets one helper per namespace. Each action picks the import that matches its path.
Opt-in, no breaking change
Actions that keep their existing import { defineAction } from '../.quickback/define-action' continue to compile unchanged. The AppContext index signature still admits ctx.event at type any; runtime hydration is unaffected. Flip imports per action at your own pace.
Compile-time advisory
When quickback compile sees a namespace-scoped action still importing the generic helper, it logs a one-line [quickback:typing] advisory pointing at the narrowed path:
[quickback:typing] action "feedMoments" in feature "feeds" lives under
namespace "eventScope" — switch its import to
`../.quickback/define-action.event-scope` for narrow ctx.<exposeAs> typing.
(Runtime behavior is unchanged; the narrowed helper only refines TS types.)Non-blocking — same family as [quickback:authz].
Side fix
Repaired a latent off-by-one in the inlined AppContext import path that landed in the v0.26 helper prelude but hadn't been exercised against a recompiled project yet ("../../lib/types" → "../../../lib/types"). New compiles produce a helper whose AppContext import actually resolves.
Out of scope
Non-namespace standalone actions with relationship-based access keep their existing (ctx.event as typeof events.$inferSelect) body cast. Same per-relationship helper pattern would apply if the demand surfaces — defer until requested.
v0.26.0 — May 19, 2026
Bulk-grant namespace lanes: org admins and team members in one line
v0.25 widened via: to accept an array of relationship lanes — attendeeOf OR organizerOf, first match wins. That covers per-row authorization paths. v0.26 adds the missing piece: bulk-grant lanes for the "you work here, so you see everything in your scope" case. Org admins, team members, and other tenant-scoped staff no longer need a per-event relationship row to be authorized for a namespace.
authz: {
namespaces: {
eventScope: {
prefix: '/event/:eventId',
via: [
'attendeeOf', // per-event relationship
'organizerOf', // per-event relationship
{ roles: ['admin', 'owner'] }, // org-role bulk grant
{ team: true }, // active-team-membership bulk grant
],
},
},
}Lanes resolve in declaration order, first match wins. Per-row relationships and bulk-grant guards compose freely.
What each bulk-grant lane does
{ roles: [...] }— passes when the caller's BA org-role for the namespace's loaded resource includes any listed role. The check is in-memory and short-circuits the relationship loop entirely. The loads-table firewall (scoped byctx.activeOrgId) does the tenant isolation; this lane just adds the role precondition on top.{ team: true }— passes whenctx.activeTeamIdis set. The loads-table firewall (which scopes by team for team-scoped tables) does the actualevents.teamId === ctx.activeTeamIdmatch during hydration. If the firewall returns no row, the request gets a contextual 404 explaining the row isn't visible to the current session — same ambiguous-by-design phrasing the firewall has always used to prevent cross-tenant ID probing.
Why this matters
Until v0.26, the only way to authorize an org admin for a namespace was to write access.or: [{ via: 'attendeeOf' }, { roles: ['admin', 'owner'] }] on every individual action under it. That OR-arm shim worked but defeated the purpose of namespaces — the whole point of hoisting the gate was to write the auth rule once. Now you do.
Rich deny payloads
The 403 response when every lane misses lists every path the caller could have taken — relationship names, role names, and "team-membership" for { team: true } lanes — in the required field. A developer hitting an unexpected 403 sees the full set of authorization options they need to satisfy, not a generic "Insufficient permissions" wall.
The 404 from hydration (when the loads-table firewall filters the request out) now includes a hint explaining that the row may exist out of scope, and a details field with the resource name and key, so a developer working in their own environment has enough to debug.
Compile-time guards
- Pure bulk-grant namespaces (no relationship lane at all) are accepted as long as
loads:+exposeAs:are declared directly on the namespace. With at least one relationship lane present, those fields are inherited from the relationship — declare them on the namespace itself only when no relationship lane is available to anchor hydration. - Heterogeneous
loads:between relationship lanes is rejected —ctx.<exposeAs>must always hydrate the same table regardless of which lane matched. namespace.loadsthat disagrees with the inherited value is rejected with a clear "drop one or align the other" message.
v0.25.0 — May 19, 2026
Multi-lane namespace gates: more than one way to be authorized
v0.24 lets you hoist a namespace gate to the project level via authz.namespaces.<name>.via: 'attendeeOf' — one relationship-resolver middleware running once per request for every action under /event/:eventId/*. That works when there's exactly one way to be authorized for the resource. It breaks the moment you have two: an attendee row and a per-event organizer row, say. The shim today is to OR the second check inside every individual action's access block, which puts you back to writing the gate over and over.
v0.25 widens via: to accept an array of relationship lanes. The middleware tries each in declaration order, and the first one whose subject row matches authorizes the request and hydrates ctx.<exposeAs>.
authz: {
relationships: {
attendeeOf: {
from: 'guests',
subject: { column: 'linkedUserId', equals: 'ctx.userId' },
resource: { column: 'eventId' },
where: { status: 'confirmed' },
loads: 'events',
exposeAs: 'event',
},
// Per-event named organizers. The subject can be a user OUTSIDE
// the event's organization — e.g. a freelance event manager.
organizerOf: {
from: 'eventOrganizers',
subject: { column: 'linkedUserId', equals: 'ctx.userId' },
resource: { column: 'eventId' },
loads: 'events',
exposeAs: 'event',
},
},
namespaces: {
eventScope: {
prefix: '/event/:eventId',
via: ['attendeeOf', 'organizerOf'], // first match wins
},
},
}The thing this actually unlocks isn't "two checks instead of one." It's per-event grants for users outside the owning organization — external co-organizers, freelance event managers, partner staff. They don't need a member row in the event's org; they just need a row in event_organizers for that specific event. The horizontal axis (org / team membership) stays for "staff at this company"; the new vertical axis (per-event relationships) handles "specifically connected to this event," regardless of payroll.
Backward compatible
The string form keeps working — via: 'attendeeOf' is sugar for via: ['attendeeOf']. No existing namespaces break; opt into multi-lane by switching to the array form when you actually need it.
Compile-time guards
The validator rejects three things at parse time:
- Undeclared lane names — every entry in the array must resolve to an
authz.relationships[*]declaration. - Lanes that disagree on
loads:orexposeAs:— if attendeeOf loadseventsasctx.eventand organizerOf loadsagendaItemsasctx.agendaItem, downstream handlers can't predict what's inctx.<exposeAs>. Lanes in one namespace must hydrate the same shape. - Empty arrays — at least one lane is required (otherwise the namespace gate is meaningless).
Follow-up: v0.26 bulk-grant lanes (shipped same day — see above)
The full 4-lane shape adds heterogeneous via: arrays accepting { roles: ['admin', 'owner'] } and { team: true } entries, so org admins and team members get the gate without writing relationship rows. v0.25 was the foundation; v0.26 lifted the existing per-action access.or: [..., { roles: [...] }] shim into the namespace and landed same-day.
v0.24.0 — May 19, 2026
Cross-feature namespaces: one hoisted gate, many features
v0.23 shipped defineTable({ namespace: { prefix, via } }) — a hoisted relationship-resolver middleware that gates every standalone action in one feature's Hono app. That covers the 95% case where a domain concept (events) and the actions under it all live in the same feature directory.
v0.24 lifts the namespace to the project level so it can span multiple features. A new authz.namespaces block on quickback.config.ts declares a path-prefix gate that any feature's standalone action can participate in by matching the prefix in its path:. One synthetic routes file, one resolver mount, one relationship match per request — regardless of how many feature directories contribute actions.
// quickback.config.ts
authz: {
relationships: {
attendeeOf: {
from: 'guests',
subject: { column: 'userId', equals: 'ctx.userId' },
resource: { column: 'eventId' },
loads: 'events',
exposeAs: 'event',
},
},
namespaces: {
eventScope: { prefix: '/event/:eventId', via: 'attendeeOf' },
},
}
// features/feeds/actions/feed-moments.ts (cross-feature)
export default defineAction({
path: '/event/:eventId/feed-moments',
method: 'POST',
// No gate boilerplate. ctx.event is typed.
});
// features/rsvp/actions/respond.ts (different feature, same namespace)
export default defineAction({
path: '/event/:eventId/respond',
method: 'POST',
});The compiler emits one src/routes/__namespace-event-scope.routes.ts mounted at /api/v1/event/:eventId/*. The file imports each contributing feature's actions map under a distinct alias (feedsActions, rsvpActions), runs the attendeeOf resolver once per request at the head of the file, and dispatches to the matching action handler.
Discovery is path-prefix-only
No new namespace: 'eventScope' field on defineAction. Actions opt in by matching path: against a declared namespace prefix — same model the table-level v0.23 form uses, just lifted to the project level. This means actions move in/out of the namespace based on their path strings, which is the simplest discovery story and matches today's convention (filenames and paths are already the discoverable surface).
Source-feature stays the action's home for metadata
OpenAPI tags, MCP tool grouping, and CMS form discovery continue to bucket each action under its source feature. Only the runtime routes file moves to the synthetic location — the feed-moments action is still tagged "feeds" in OpenAPI, the response shape doesn't change, and your generated SDK reads identically. The URL conveys the namespace membership without forcing every layer of the compiler to re-anchor the action.
Worker-root mount order
Synthetic namespace routes files register at priority 20 — ahead of per-feature mounts (priority 0/10). sortRoutesBySpecificity keeps the order stable, and the namespace prefix's path parameter makes it sort more specific than /api/v1/<feature> anyway, so the middleware fires first for every request under its prefix.
Compile-time guardrails
The same v0.23 invariants apply at the project level, with two additions:
via:must point at a declaredauthz.relationships[*]entry, and that relationship must declareloads + exposeAs— withoutloadsthere's nothing for the middleware to hydrate, and a namespace that only gates without hydrating is indistinguishable from a per-action access rule.prefix:must contain at least one:paramsegment — middleware runs before input parsing, so the relationship key comes fromc.req.param().- Same-name conflict between
defineTable({ namespace })andauthz.namespaces.<sameName>is rejected. Pick one declaration site. - Ambiguous prefixes like
/a/:xand/a/:y(same shape, different:paramnames) error at compile time. Strict super/sub-set relationships (e.g./event/:eand/event/:e/admin) are fine — the harvester routes each action to the longest matching prefix. - Unused namespace warning emits
[quickback:authz]when no standalone action's path matches a declared prefix. Not a hard error — the namespace may be staged ahead of the actions.
Picking the right declaration site
The table-level v0.23 form is now the single-feature shorthand. The project-level v0.24 form is canonical for cross-feature.
- Use
defineTable({ namespace })when every action under the prefix lives in the same feature directory. The namespace travels with the table that owns it; nothing to add toquickback.config.ts. - Use
authz.namespaceswhen the namespace spans features. The declaration sits next to yourauthz.relationshipsandauthz.roles— the canonical home for cross-cutting authz config.
The two forms can't coexist for the same namespace name. If a v0.23 table-level namespace is starting to attract cross-feature actions, lift it to authz.namespaces — same shape, just at the project level, and the matching paths in other features will join automatically.
Migration
Zero breaking changes. Nothing in v0.23 needs to change for v0.24 to land. The new field is opt-in; existing projects pick it up the moment they declare an authz.namespaces entry.
The harvester runs after Zod schema validation but before route generation, so unused or misconfigured namespaces surface immediately as errors or warnings rather than as silently-broken routes.
Fixes shipped alongside
The first two were load-bearing for v0.23 namespaces to actually work — projects upgrading from a pre-fix v0.23 will see crashes/compile errors disappear on the v0.24 recompile.
- No more dual emission of namespace-claimed standalone routes. Pre-fix, an action with a cross-feature path like
/event/:eventId/agenda/list-my-agendaemitted into bothsrc/index.ts(without the namespace resolver) and the synthetic file (with it). The plain root copy insrc/index.tswon every URL match andctx.<exposeAs>was never hydrated — every request 500'd withCannot read properties of undefined (reading 'id').extractStandaloneActionsnow skips namespace-claimed actions; the synthetic namespace file is the sole emission. - Stripped-path validator no longer rejects namespace-claimed actions. The synthetic file mounts at
/api/v1${namespace.prefix}and strips the prefix from each action's local path. The per-action relationship-resolver was validating the stripped path for:eventId, finding nothing, and rejecting every namespace-mounted action with "action's surface exposes neither a path parameter ':eventId' nor an input field 'eventId'".planRelationshipResolvernow receives the namespace'sviaand skips its surface-key check entirely — the hoisted middleware is the single source of truth. - Broadcaster DO migration tag consistent across
realtime.mode. Inline-mode emission was already going throughqb-do-${className}+new_sqlite_classes. Separate-mode was still hardcodedtag = "v1"+new_classes, so flippingrealtime: { mode }produced different migration tags for the same DO class and tripped CF error 10074 ("class already depended on") on cross-mode deploys. Both modes now emittag = "qb-do-Broadcaster"+new_sqlite_classes. - Batch handler id arrays type-derive from the input parser. Pre-fix,
validIds,recordMap, andvalidEntriesin bulk-delete / bulk-update / bulk-upsert were hardcodedstring[]/Map<string, any>even when the table's PK column wasinteger("id")— 274× TS2769 in projects mixing string and integer PKs. Handlers now declaretype IdT = typeof ids[number](ortypeof records[number]['id']) and use it for the local id types. Strings stay strings, integers stay integers, no emitter knowledge of the PK column type required. ctxin action handlers is nowAppContext, notany. BothStandaloneExecuteParams.ctxandRecordExecuteParams.ctxin the generated<feature>/.quickback/define-action.tsare typedAppContext. AppContext keeps its[key: string]: anyindex signature so hydrated namespace keys (ctx.event,ctx.<exposeAs>) stay accessible without TS2339; named fields (userId,activeOrgId,roles,authenticated,user) get autocomplete and type-checking. Cleared ~49× TS2339 in generated projects.c.get('ctx') as AppContextin every emitted route handler. Pre-fix,const ctx = c.get('ctx')returnedany, soctx.roles?.some(r => …)left the callback parameter implicitlyany— 16× TS7006. Nowctx.rolesisstring[] | undefined,risstring, no callback annotation needed.
Deferred (tracked, not blocking)
- Per-namespace narrow typing of
ctx.<exposeAs>(soctx.eventresolves totypeof events.$inferSelectinstead ofany). The compiler emits a typed cast at theexecute()call site in the routes file today, which type-checks the call but not the handler body. Full narrow typing requires per-namespace helper emission (onedefine-action.<namespace>.tsper namespace, or a generic param threading through the action config). Tracked separately.
v0.23.0 — May 19, 2026
Authz relationships: compile-time guarantees, less boilerplate, hoisted middleware
This release closes the gap between what authz.relationships promised in v0.19 and what it actually enforced. Every documented invariant is now a real compile-time check; every per-action ergonomic friction has a fix. Net effect: relationship-based authorization feels native — declare the relationship once, use the role anywhere, the compiler does the wiring.
Compile-time guarantees (the authz firewall parity)
The org/owner/team firewall has always been compile-time-checked: a routed resource without a scope fails the compile, full stop. As of this release, authz.relationships carries the same guarantees:
- Bad
via:names fail at parse time. A typo in a firewall predicate (firewall: [{ field: 'eventId', via: 'guestOff' }]) used to surface deep inside the firewall generator after most validation had passed. It's now caught alongside theauthz.rolesvia:validation, same layer, same error format. - Relationship-from tables can't opt out. The doc at
define/types/authz.ts:73-74always said a relationshipfrom:table withfirewall: { exception: true }"refuses to compile" — that's now enforced. Without this check, everyvia:subquery that pivots through an exception-marked table loses tenant isolation and can return rows from other tenants. allowResourceMigrationis now mandatory when the relationship'sresource.columnis on a writable update endpoint. A guest doingPATCH /messages/:id { eventId: <other-event-id> }to migrate a row out of their access scope used to slip past the runtime — it now fails compile with a three-option remediation (set the flag, mark the column immutable, or drop it from the guards.updatable allowlist).via: string[]in firewall predicates. "Member of attendees OR speakers" no longer requires inventing a composite role just to use it once in a firewall —firewall: [{ field: 'eventId', via: ['attendeeOf', 'speakerOf'] }]lowers to an OR-combined subquery.
Less boilerplate: auto-derivation + realtime resolver
resource.columnauto-derives from FKs. Omit it and the compiler scans the relationship'sfrom:table for foreign-key columns, drops thesubject.column, and picks the remaining FK. One match → auto; zero or many → error with the candidate list. Pattern mirrors the existing firewall auto-detection.realtime.requiredRolesaccepts authz roles. Previously a relationship-role inrealtime.requiredRolesfell through with a misleading "unknown role" error. The compiler now flattens declared authz roles into their composing Better Auth roles before the realtime ticket-handshake sees them. Relationship-only roles (no static BA fallback) emit a[quickback:authz]warning and fail-close at handshake — the row-stream firewall continues to enforce the relationship dimension on the data side.
Record-based actions can finally use relationship-roles
The v0.19 fail-closed hardening had an unintended side effect: a record-based action with access: { roles: ['attendee'] } (where attendee was a relationship-role) 403'd every caller, including the legitimate ones. The runtime helper couldn't evaluate relationship: arms, so it fail-closed correctly — but the inline match expression that would evaluate the relationship was never wired up for record-based actions.
This release routes record-based actions with relationship arms through the same inline access generator the resource CRUD path already uses. roles: ['attendee'] on /applications/:id/recap now actually checks whether the caller is an attendee, with the loaded record in scope. Non-relationship access trees keep the runtime path unchanged.
Typed ctx.<exposeAs> in action handlers
When a relationship declares loads: 'events', exposeAs: 'event', the runtime hydrates ctx.event with the loaded row — but the static type was always any, so handlers had to write const event = ctx.event as typeof events.$inferSelect. As of this release, when an action's access references exactly one relationship with loads + exposeAs, the compiler emits the cast for you at the execute() call site. Handlers read ctx.event with the right shape; no per-handler boilerplate.
Multi-arm OR actions (referencing two relationships with hydration) keep the any shape — we can't statically know which arm matched.
New: defineNamespace — one mount, one resolver, many actions
The marquee Wave 3 change. Today's pattern for "five standalone actions under /event/:eventId/* all gated by attendeeOf" requires writing the same inline relationship resolver in each action — and pays the resolver cost (one subquery, one row-load) on every request, multiplied by N actions. The new namespace field on defineTable hoists the resolver into a single Hono middleware mount:
// features/events/events.ts
export default defineTable(events, {
firewall: [...],
crud: { ... },
namespace: {
prefix: '/event/:eventId', // absolute URL path the mount covers
via: 'attendeeOf', // relationship gate
},
});
// features/events/actions/post-announcement.ts
export default defineAction({
path: '/event/:eventId/announcements',
method: 'POST',
// No relationship-gate boilerplate. ctx.event is typed
// `typeof events.$inferSelect` inside execute().
});The compiler:
- Emits one
app.use('/:eventId/*', async (c, next) => { /* relationship resolver */; await next(); })at the head of the feature's standalone-routes file (path-prefix stripping handles the feature mount). - Skips the inline relationship resolver in every per-action handler under the prefix — the middleware already ran it.
- Hydrates
ctx.eventonce per request and threads the typed cast into every namespace action's execute() call.
Validation guardrails:
namespace.viamust point at a declaredauthz.relationships[*]entry.- The referenced relationship must declare
loads + exposeAs— withoutloadsthere's nothing for the middleware to hydrate, and the namespace gate would be indistinguishable from a per-action access rule. namespace.prefixmust contain at least one:paramsegment — the middleware reads the relationship key fromc.req.param(), not from request body (body parsing hasn't run at middleware time).
Scope (v1):
- Namespace lives within a single feature's Hono
app; cross-feature shared namespaces are deferred to v2. - One namespace per
defineTabledeclaration; multiple namespaces in a feature mount as siblings. - For namespace actions whose access references additional relationships beyond the namespace's via, the inline resolver still fires for those — namespace handles only its own.
Migration
Nothing in v0.20 → v0.23 breaks existing code that compiles cleanly. The new errors fire only against patterns that were already latent bugs:
- A
via:typo that previously surfaced as a deep codegen error now surfaces at parse time, same message. - A
firewall: { exception: true }on a relationship-from table now errors; this configuration was always documented as unsupported. - A relationship's
resource.columnwritable on the resource's UPDATE endpoint now errors unlessallowResourceMigration: trueis set; previously this was a runtime escape hatch. - Record-based actions with
roles: ['<relationship-role>']previously 403'd every caller; they now work as the v0.19 docs originally promised.
Why this matters
Authz relationships are how Quickback expresses "the caller is allowed to touch this resource because of a row in that table" — guest-of-event, member-of-organization, scorer-of-interview. Until now, the feature worked but had sharp edges: typos surfaced late, the documented exception-not-allowed constraint wasn't enforced, and the namespace pattern (which most apps with nested resources end up wanting) required repeating the same gate in every action. This release brings authz to parity with the org/owner/team firewall: declare it once, the compiler enforces it everywhere, the runtime emits exactly one query per request.
v0.22.0 — May 19, 2026
Quickback Stack is Cloudflare-only: one runtime, two databases, one auth provider
We've focused the platform on the stack we actually believe in: Cloudflare Workers + Hono + Better Auth, with Cloudflare D1 or Neon as the database. Providers and modes that targeted non-Cloudflare backends are gone. The result is a smaller concept set, fewer footguns, and clearer docs.
What's been removed
- Runtimes:
bunandsupabase-edgeare no longer supported. The only runtime iscloudflare. Local development for compiled projects has always usedwrangler dev— that's unchanged. - Databases:
supabase,libsql,better-sqlite3,bun-sqliteare removed. Usecloudflare-d1for SQLite at the edge orneonfor PostgreSQL via Hyperdrive. - Auth providers:
supabase-authis removed.better-authis the default;external(Cloudflare service binding) remains for advanced setups. - Template:
template: 'nextjs'is removed from the public config type. It was never fully implemented. The only template is'hono'. If you need a Next.js frontend, bring it as a separate deploy and call the Quickback API, or drop a static export into theapps:mount. - Quickback for Supabase RLS product line is retired. The compiler no longer emits Supabase Row Level Security policies. The
/products/quickback-supabase-rls/and/for/supabase-users/pages have been removed from the marketing site, and thecompiler/targets/supabase-rls/docs section has been deleted.
Account UI: one delivery mode
The standalone quickback-better-auth-account-ui npm package has been deprecated and the upstream GitHub repo (Kardoe-com/quickback-better-auth-account-ui) has been archived. Account UI is now exclusively delivered the way 99% of projects already use it: the compiler builds it from source and bundles it into your Worker.
account: { domain: 'auth.example.com', ... }inquickback.config.ts— that's the entire integration story.- The compiler builds the SPA inside Docker with your branding and feature flags baked in, emits hostname-based routing, and wires cross-subdomain cookies automatically.
- The three standalone delivery-mode docs (
stack/account-ui/standalone,library-usage,worker) are removed; the remaining pages explain the compiler-bundled flow.
If you previously deployed the standalone Account UI Worker against a Quickback backend, switch to account: { domain } in the config — same end result, one less deploy.
VITE_AUTH_ROUTE hardcoded to Quickback
The VITE_AUTH_ROUTE env var (which used to switch between quickback and better-auth routing dialects) is gone. The auth-client always uses Quickback's namespaced routes (/auth/v1, /api/v1, /storage/v1). Generic Better Auth backends are no longer a supported target. Removing the var also closes a known footgun where setting the wrong value silently 404'd every auth call.
New: every config option is now discoverable
When you bootstrap a project, the kitchen-sink marker block in quickback.config.ts lists every option Quickback supports — with descriptions and sensible defaults — commented out for you to copy above the marker as needed. Three top-level options were missing from this discovery surface and are now included:
apps— mount your own prebuilt SPA on its own hostname (apps: { www: { domain: 'www.example.com', aliasDomains: ['example.com'] } }). Drop the build atquickback/apps/<name>/and the compiler serves it from your Worker alongside the API. Reserved names:cms,account,public.security— HTTP security headers (CSP, COOP, COEP, HSTS, Referrer-Policy, Permissions-Policy, X-Frame-Options). Setsecurity: falseto suppress everything; defaults are sensible for the embedded SPAs.authz— relationship-based authorization roles. Declare joins between the caller and a resource column, then bind role names to those joins for use inaccess: { roles: [...] }.
Migration
Projects that compile cleanly against v0.21 and use any of these providers will hit a clear error at compile time:
Unknown database provider: "supabase". Available: cloudflare-d1, neonTo migrate:
- From Supabase: switch
providers.databasetocloudflare-d1orneon, andproviders.authtobetter-auth. YourdefineTable()definitions transfer as-is; the security DSL (firewall, access, guards, masking) compiles to Better Auth + Drizzle queries instead of RLS policies. - From Bun runtime /
bun-sqlite: switchproviders.runtimetocloudflareandproviders.databasetocloudflare-d1. Local dev becomeswrangler dev. - From libsql / Turso: switch to
cloudflare-d1orneon. Both run natively from Cloudflare Workers via the right binding. - From
template: 'nextjs': deploy Next.js separately and call the Quickback API, or drop a static export intoapps:.
Why this matters
The Quickback Stack is the Supabase alternative for Cloudflare. Every Quickback project ships a Hono API, an Account SPA, a CMS SPA, optional realtime/storage/queues — all built from one config and bundled into one Worker. Keeping that promise focused is more valuable than supporting deployment targets that don't share the runtime.
v0.21.0 — May 19, 2026
Auto-firewall column swap: ownerId is now canonical on feature tables, userId warns
The compiler's default auto-firewall column for feature tables flips from userId to ownerId. This closes a long-standing footgun where a userId column meaning "a user this row references" (a member, a contact, an invitee) got silently clobbered with ctx.userId on insert, because the wrapper matched purely on column name across every table in the project.
What changed in the compiler
BETTER_AUTH_DEFAULTS.owner.column:'userId'→'ownerId'. Schemas with anownerIdcolumn auto-resolve tofirewall: [{ field: 'ownerId', equals: 'ctx.userId' }]and haveownerIdauto-stamped on insert.- The generated
OWNER_SCOPE_RULESinsrc/lib/scoped-db.tsis seeded withownerIdinstead ofuserId. Schemas that don't explicitly nameuserIdin afirewall:block no longer trigger the globaluserId-stamping rule — the most common collision goes away without renames. - A new compile-time warning fires when a feature table carries both an auto-resolved isolation column AND a
userIdcolumn. The warning recommends renaming the column tolinkedUserId(or similar) if it's informational, or declaringfirewall: [{ field: 'userId', equals: 'ctx.userId' }]if it really is the access boundary on that table. - The targeted compile-time error for "the only candidate column is
userId" now points users atownerIdand the explicit-firewall escape hatch. The previous error wording (which assumedownerIdwas business-logic) is removed.
What stayed the same
- Better Auth's own internal tables (
session,account,member,passkey,subscriptions) still useuserIdby convention. They aren't feature tables and don't pass through the auto-firewall. - Explicit
firewall: [{ field: 'userId', equals: 'ctx.userId' }]declarations continue to work and are still recognized as the "owner" pattern in the generated comment header. - The
USERpseudo-role validator behaviour is unchanged; only the error-message wording flips to lead withownerIdand listuserIdas the explicit fallback. - Audit-wrapper (
createdBy/modifiedBy) behavior is untouched.
Migration
Projects that auto-firewalled on a userId column will see a hard error on next compile telling them which table is affected, with two suggested fixes:
- Recommended: rename the column to
ownerIdin the schema. Cleanest semantics — readers immediately understand the column is access-control. - Explicit firewall: keep the
userIdcolumn and addfirewall: [{ field: 'userId', equals: 'ctx.userId' }]to the resource. The auto-stamping and auto-scoping still happen; you've just opted in by name.
Projects that have a userId column meaning "a user this row references" no longer need the linkedUserId rename workaround — auto-stamping won't touch the column unless you name it in a firewall block. The new warning makes this audit-discoverable.
Caveat (deferred): explicit firewall: { owner: { column: 'X' } } declarations still populate the project-global OWNER_SCOPE_RULES list, so a hand-named owner column on one resource will still match by name on every other table that happens to have a column called X. A future release will make scope-rule resolution per-table rather than project-global. The warning text calls this out so callers know what to expect.
v0.20.0 — May 19, 2026
CMS + Account SPAs: shadcn base-ui migration, central Tailwind config
Both first-party SPAs are now on the shadcn base-ui variants (@base-ui/react) instead of the Radix-based primitives, applied via shadcn preset bdvxIkMq (style base-mira, baseColor: zinc, iconLibrary: remixicon, Inter Variable font). The migration also collapses the previously duplicated per-SPA theme + components.json into a single source at packages/ui/.
Single source of truth in packages/ui/
Both SPAs were independently declaring the same @theme / :root / .dark token blocks in their own app.css, and each had its own components.json pointing at non-existent local component directories. That's gone:
packages/ui/styles/theme.css— the only place tokens live. Includes the base-mira palette, sidebar + chart tokens, and the Inter font import.packages/ui/components.json— the only shadcn config in the repo. Futurepnpm dlx shadcn@latest add <name>runs frompackages/ui/.packages/cms/app/app.cssandpackages/account/app/app.cssreduce to two imports plus SPA-specific layers (CMS keeps its AG Grid + richtext-display overrides).packages/{cms,account}/components.jsondeleted.
Components, icons, deps
- All 22 shadcn primitives regenerated against
@base-ui/react(@radix-ui/*removed everywhere).Drawerstays onvaul— base-ui has no Drawer primitive. - Icons migrated from
lucide-reactto@remixicon/reactline variants across ~100 source files (Plus → RiAddLine,ChevronDown → RiArrowDownSLine,X → RiCloseLine, etc.).lucide-reactremoved from allpackage.json+deps-spa-*files. pnpm.overridespin@types/react ^19.2.0workspace-wide to resolve the duplicate-React-types conflict introduced by@base-ui/react's bundled type peer.- Per-SPA deps and the Docker build mirrors (
apps/compiler/deps-spa-{cms,account}-package.json) both gain@base-ui/react,@remixicon/react,@fontsource-variable/inter, and bumpshadcnto^4.7.0.
Backwards-compat asChild shim
Base-ui replaces Radix's asChild boolean with a render={<X />} prop. To avoid rewriting ~44 caller sites, the shadcn-generated wrappers for Button, DropdownMenuTrigger, DropdownMenuItem, and PopoverTrigger carry an asChild?: boolean prop that delegates to base-ui's render API when set:
// Caller code keeps working as-is:
<Button asChild>
<Link to="/foo">Click</Link>
</Button>ResponsiveModal survived the migration unchanged — it composes the higher-level Dialog + Drawer wrappers (preserved public API), not the underlying primitives.
Caller-side API changes worth noting
Base-ui ships some real behavioural changes the Radix variants didn't:
<Select onValueChange>now receives(value: string | null, eventDetails) => void(was(value: string) => void). ExistingDispatch<SetStateAction<string>>setters rejectnull— wrap callbacks defensively:onValueChange={(v) => v != null && setX(v)}.<DropdownMenuContent forceMount>removed (base-ui has noforceMount). Use base-ui'skeepMountedon the Popup/Positioner if you need to preserve unmounted state.- Component sub-names shifted (
Dialog.Content→Dialog.Popup,Dialog.Overlay→Dialog.Backdrop). Data attributes are nowdata-open/data-closed(no moredata-state="open").
Adding a new primitive after this release
cd packages/ui
pnpm dlx shadcn@latest add <component> --overwrite --yes
# Sweep generated `@/` imports to relative paths — the SPAs' vite-tsconfig-paths
# plugin doesn't see packages/ui's @/* mapping at bundle time:
sed -i.bak \
-e 's|"@/lib/utils"|"../lib/utils"|g' \
-e 's|"@/components/|"./|g' \
-e 's|"@/hooks/|"../hooks/|g' \
components/<component>.tsx && rm components/<component>.tsx.bakIf the new primitive pulls in fresh deps, mirror them into apps/compiler/deps-spa-{cms,account}-package.json per the SPA build pipeline or Docker SPA builds will fail.
v0.18.1 — May 18, 2026
Account SPA — teams, active-team picker, permission-aware UI
The Account SPA's organization surface is now feature-parity with Better Auth's organization plugin for the basic team workflow, and replaces the long-standing hardcoded role === 'owner' || 'admin' checks with checkRolePermission so projects that ship custom roles get a UI that respects them.
Teams (opt-in)
When the BA config enables teams — organization({ teams: { enabled: true } }) — the compiler emits VITE_ENABLE_ORG_TEAMS=true and the Account SPA exposes a Teams tab on each organization:
- Teams index (
/$slug/teams) — splits into "Your teams" (fromlistUserTeams) and "Other teams" so non-admins land on what's relevant. Create / rename / delete from the list, gated onteam:create/team:update/team:delete. - Team detail (
/$slug/teams/$teamId) — member roster with add / remove, joininglistTeamMembersagainst the org's full member list for display. - Active-team picker in the org header — calls
setActiveTeamand exposes the choice viaOrganizationContext.activeTeamIdso downstream routes (and the BAsession.activeTeamIdserver-side) stay in sync. - Team-aware invites —
InviteMemberDialogshows an optional team picker when teams are on and forwardsteamIdtoinviteMember, so accepting the invite drops the new member straight into the right team.
Teams are loaded once by the $slug layout (listTeams + listUserTeams) and exposed via OrganizationContext.teams / userTeamIds / refreshTeams, so individual team routes don't re-fetch the list.
Permission-aware UI via checkRolePermission
OrganizationContext.isOwnerOrAdmin is gone. Routes now read can(resource, action) from context, which delegates to authClient.organization.checkRolePermission — synchronous, no roundtrip, and aware of any custom roles registered via the BA ac option. Multi-role users (BA stores multiple roles comma-separated) are handled by checking each role; the action is allowed if any role grants it.
Migrated sites:
$slug.tsxtab gates →can('invitation', 'create'),can('organization', 'update')members.tsxaction menu →can('member', 'update' | 'delete'); the owner-hierarchy guard (non-owners can't touch owner-role members) is preserved as a client-side mirror of BA's server enforcementinvitations.tsx→can('invitation', 'create' | 'cancel')gates the Invite button, resend, and cancel separatelysettings.tsx→can('organization', 'update')for the form,can('organization', 'delete')for the danger zone (was owner-only)teams.tsx/teams.$teamId.tsx→can('team', 'create' | 'update' | 'delete')
Projects on the default three-role hierarchy see no behaviour change; projects that defined admin/member overrides via BA's access-control API now get a UI that matches what the server will actually authorize.
Editable slug + metadata on organization settings
The $slug/settings.tsx form now edits two more organization.update fields:
- URL slug — full debounced
checkSlugflow (mirroring the create-org form); on rename, the SPA navigates to/${newSlug}/settingsand the layout re-fetches with the new slug. - Metadata — single
Record<string, any> | nullJSON textarea, parsed on submit with inline error messaging. Empty clears the field.
Slug rename is non-breaking for the SPA itself, but every external link / bookmark that references the old slug becomes a 404 — the form warns about this before saving.
Auth client wiring
When VITE_ENABLE_ORG_TEAMS=true, the SPA's organizationClient(...) is instantiated with teams: { enabled: true } so the team APIs are typed end-to-end. When the flag is off, team methods aren't surfaced — and the Teams tab is hidden — keeping the bundle and UI honest about what the server actually supports.
v0.18.0 — May 17, 2026
config.apps — mount user-authored SPAs on dedicated hostnames
The compiler now generates per-hostname Worker middleware for arbitrary user SPAs alongside the built-in CMS and Account UIs. Previously the only way to host a product app next to a Quickback API was a second Worker; config.apps brings the full stack — API, account, and product UI — onto one deployment with same-origin auth.
apps: {
www: {
domain: "www.example.com",
aliasDomains: ["example.com"], // apex serves the same SPA
},
}Each entry binds one or more hostnames to a directory under src/apps/<name>/ and inherits the Account-domain pass-through behavior — /api/, /auth/, /admin/, /storage/, and /health route to Hono so the SPA fetches its API without CORS. The compiler:
- emits a
custom_domain = trueroute inwrangler.tomlfor the primary hostname and every alias - adds each hostname to Better Auth's
TRUSTED_ORIGINS(whentrustedOriginsis left unset) - widens the CSP
connect-srcto cover the SPA-baked origins - folds the new hostnames into shared-parent detection for
crossSubDomainCookies
Drop the prebuilt SPA bundle at quickback/apps/<name>/ — the existing source-apps passthrough copies it byte-for-byte into src/apps/<name>/. The compiler doesn't build the SPA; it only emits routing for whatever's already there. The names cms, account, and public are reserved; hostname collisions with cms.domain / account.domain / account.adminDomain or between two apps[*] entries fail at compile time.
When cms and account are both configured, the multi-domain catchall now explicitly skips app hostnames — an API miss on an app host returns the standard 404 JSON via app.notFound instead of stray-serving a sibling SPA's HTML shell.
Cross-subdomain cookie inference handles apex aliases
The shared-parent detection that drives advanced.crossSubDomainCookies previously reduced any hostname by stripping the leftmost label — 'example.com' collapsed to 'com', which broke equality against sibling subdomains. Apex hostnames (exactly two dot-segments) are now treated as already being the parent, so apex aliases (example.com listed alongside www.example.com) coexist cleanly with auth.example.com / cms.example.com and the compiler still emits crossSubDomainCookies: { enabled: true, domain: '.example.com' }.
v0.17.2 — May 15, 2026
customPlugins escape hatch on defineAuth("better-auth", …)
The Better Auth provider now accepts customPlugins alongside the named-plugin registry — a path to ship third-party plugins (@better-auth-kit/audit, …), unregistered built-ins, or one-off project-local plugins without forking the compiler.
Each entry is an explicit import descriptor:
auth: defineAuth("better-auth", {
emailAndPassword: { enabled: true },
plugins: { passkey: true, organization: true },
customPlugins: [
// (1) Project-local plugin file at quickback/plugins/long-session.ts
{ from: "./plugins/long-session", export: "longSessionPlugin" },
// (2) Third-party npm package
{
from: "@better-auth-kit/audit",
export: "auditPlugin",
args: { events: ["sign-in"] },
dependencies: { "@better-auth-kit/audit": "^1.0.0" },
},
],
}),from is either a relative path under ./plugins/ (the CLI auto-uploads matching files from quickback/plugins/; the compiler copies them into src/plugins/<name>.ts and rewrites the emitted import) or a bare npm specifier (emitted verbatim; declare dependencies so the package lands in the generated package.json). Omit args to emit the symbol uncalled (plugin instance); provide args to emit a JSON-serialized factory call.
The descriptor is validated with Zod — typos in from/export, paths that escape ./plugins/, and references to files the CLI didn't ship all fail at compile time with a targeted message.
v0.17.1 — May 14, 2026
quickback start — interactive onboarding
mkdir my-app && cd my-app
npx @quickback-dev/cli startstart walks new users through template selection, scaffolds the project on disk, and only prompts for an account when it's ready to compile. Cancelling at the login prompt leaves the scaffold intact — sign in and run quickback compile whenever you're ready.
The same deferred-auth flow applies to quickback create. Scaffolding and (optional) Cloudflare D1 setup run before authentication, so you can preview the project on disk before committing to an account.
Named views on the free tier
The todos template now ships a dashboard named view, exposing a projected response at GET /api/v1/todos/views/dashboard. Named views support per-view access rules and per-view field selection while inheriting the table's firewall and masking — the canonical way to ship list-view payloads that are cheaper to fetch and easier to cache than a full collection read.
quickback init removed
Folded into start and create. The legacy command name prints a deprecation notice pointing at its replacements.
Minor
quickback whoamireads the stored tier from your credentials.- Pro upsell copy in
quickback loginis shown only when the server returns an explicit free-tier subscription. - Bare scaffolds (
cloudflare,empty) report accurately when there are no features to generate migrations for.
v0.16.10 — May 14, 2026
D1-specific safety pass on generated migrations
The cloudflare-d1 provider now runs a post-generation pass over every Drizzle migration directory (auth, features, webhooks, audit) before the compile finishes. Better Auth cleanup for retired managed tables is stripped from raw DROP TABLE statements. Drop-only cleanup migrations are reordered child-first when foreign-key dependencies require it. Every generated D1 migration directory is replayed against SQLite during compile, so a D1-breaking migration fails at compile time rather than at wrangler d1 migrations apply.
v0.16.9 — May 13, 2026
Resource-scoped realtime tickets
providers.database.config.realtime accepts an object form for projects whose WebSocket subscribers aren't org members:
realtime: {
wsTicket: {
role: "attendee",
requestField: "eventId",
scopeTable: "events",
},
}The generated /broadcast/v1/ws-ticket route authenticates the caller, evaluates the declared relationship role against the request, and mints a short-lived WebSocket ticket scoped to the resolved resource. realtime: true continues to enable the org-scoped broadcaster.
Unified broadcast targeting
The realtime helper and Broadcaster now route by scopeKey || organizationId || userId. String targets remain shorthand for organizationId; explicit targets can scope a broadcast to a resource or a single user:
await realtime.broadcast("event-page-updated", payload, { scopeKey: "events:evt_123" });
await realtime.broadcast("dm", payload, { userId: recipientId });WebSocket tickets are Quickback-minted (signed with BETTER_AUTH_SECRET), not Better Auth tokens — Better Auth remains the front-door auth check; Quickback signs the upgrade ticket after authorization succeeds.
v0.16.8 — May 13, 2026
One auth transport per request
Generated middleware treats Cookie, Authorization: Bearer, and x-api-key as distinct auth methods and rejects mixed requests with 400 AUTH_METHOD_CONFLICT. Query-string API keys (?api_key= / ?key=) are no longer accepted. Machine-to-machine auth is explicit; precedence rules between transports are gone.
Cookie-backed CSRF gate
Generated apps now block cross-site POST / PUT / PATCH / DELETE requests from cookie-relying callers. The middleware skips explicit Authorization clients, requires a trusted Origin or Referer, and blocks Sec-Fetch-Site: cross-site. This matters most for cross-subdomain session setups where SameSite=None is intentional.
First-class Wrangler observability
providers.runtime.config.observability now supports nested logs / traces blocks for headSamplingRate, destinations, persist, and logs.invocationLogs, emitting matching [observability], [observability.logs], and [observability.traces] TOML.
v0.16.7 — May 12, 2026
Bearer wins over cookie
A request carrying both Authorization and a session Cookie resolves entirely from the bearer; the cookie is dropped before getSession() runs. All three Bearer flavors (API key, JWT, session-token) now behave consistently — no more silent cookie precedence on session-token bearers.
Bearer plugin defaults to requireSignature: true
Better Auth's bearer plugin defaults to unsigned tokens; Quickback now emits bearer({ requireSignature: true }). Tokens emitted in the CORS-exposed set-auth-token header are server-signed with BETTER_AUTH_SECRET and verified on every request. Projects that genuinely need unsigned bearers can override via providers.auth.config.plugins.bearer.
See Authentication Security → Bearer Tokens and Cookie Precedence.
v0.16.5 — May 12, 2026
Flat write DSL
Resource write operations are now declared at the table level alongside read, replacing the nested crud block:
defineTable(customers, {
read: { ... },
create: { access: { roles: ["admin"] } },
update: { access: { roles: ["admin"] } },
delete: { access: { roles: ["admin"] }, mode: "soft" },
upsert: { access: { roles: ["admin"] } },
guards: { ... },
});crud and top-level put still compile as deprecated compatibility aliases. Mixed usage on the same resource is rejected. schema-registry.json emits both shapes during the transition.
CMS placement for multi-table feature actions
Standalone actions on multi-table features have an explicit placement model:
cms.placement: "feature"shows the action on every table toolbar in the featurecms.placement: "tables"withcms.tablesscopes it to specific tablescms.hidden: truekeeps it API-only
Feature-root standalone actions are emitted under a new featureActions bucket in schema-registry.json. Ambiguous multi-table standalone actions without placement compile with a warning.
openapi.types accepts a string array
One compile can write the generated types file to multiple locations, keeping colocated SPA clients in sync:
openapi: { types: ['api-types.gen.ts', 'apps/web/src/lib/api-types.gen.ts'] }Stable artifact ordering
Generated route files and schema-registry.json are emitted in a stable order across runs. Identical logical input produces identical artifacts.
v0.16.4 — May 12, 2026
Drizzle snapshot integrity
Drizzle metadata files (<n>_snapshot.json) are no longer stamped with a Quickback warning key. The warning is now journal-only. Projects whose snapshots were corrupted by an earlier release self-heal on the next compile — the stripped header is removed during staging and the rewritten snapshot is clean.
account.auth.* drives the server, not just the SPA
The Account UI's auth flags (signup, password, passkey, emailOTP, emailVerification) now configure the Better Auth server alongside the SPA bundle. A normalizer maps them into the server config:
signup → emailAndPassword.disableSignUp(inverted)password → emailAndPassword.enabledemailVerification → emailAndPassword.requireEmailVerificationpasskey/emailOTP→ plugin additions
The SPA's VITE_ENABLE_* flags are derived from the same canonicalized server config, so the bundle and the server cannot drift. Two new flags ride along: VITE_ENABLE_API_KEYS and VITE_ENABLE_MAGIC_LINK.
Breaking for projects that implicitly relied on the SPA-only behaviour: account.auth.signup: false now actually disables POST /auth/v1/sign-up/email on the server. Set providers.auth.config.emailAndPassword.disableSignUp: false explicitly to keep server signup open while hiding the SPA screen.
v0.16.3 — May 9, 2026
Standalone action mount order
Feature-level standalone action routers now register before sibling CRUD routers when both mount at the exact same prefix. A literal path like GET /api/v1/records/today-bundle resolves to the standalone handler instead of falling through to GET /:id and treating "today-bundle" as a record ID.
v0.16.1 — May 9, 2026
Cloudflare ASSETS canonicalization
Worker SPA fallbacks now request /<dir>/ instead of /<dir>/index.html. Cloudflare's static-assets binding rewrites /index.html requests as 307 redirects to the trailing-slash path under the default html_handling=auto-trailing-slash, which previously caused redirect loops in SPA fallback handlers. Affects CMS, Account, admin, and the multi-domain catchall.
account.customLogin
Set account: { customLogin: true } to drop a project-branded sign-in page in front of the bundled SPA. Place the page at quickback/public/account/login/index.html (as a directory) and the existing public passthrough serves it. The route registers before the /account/* SPA catch-all so the static handler wins; everything else under /account/ continues to route through the bundled SPA.
Project-level realtime defaults
providers.database.config.realtime: true now treats every resource as realtime-enabled by default. Per-resource realtime: { enabled: false } still wins.
Compile-time validators
Two new compile-time errors over silent runtime failures:
AUTHENTICATED+ org-scoped firewall is rejected. The combination silently returned empty lists to sign-in-only callers who lacked an active org; the validator names three valid resolutions (drop the org scope, useUSER, or split the route).createRealtimeimports withoutrealtime: trueenabled in the config are rejected at compile.
Drizzle metadata hardening
Drizzle journal files carry a "do not edit" header. The compiler warns when the journal entry count exceeds the snapshot file count (typically the trace of a hand-deleted snapshot).
Minor
SYSADMINis now recognised by the standalone-action org gate's pseudo-role matcher.- Apex
/and/account/loginget fallback redirects toauth.loginUrlwhen neither bundled SPA is enabled. - Record-based action URLs are kebab-case to match the OpenAPI spec (e.g.
/api/v1/widgets/:id/send-invite).
v0.16.0 — May 9, 2026
Agent Auth Protocol
Quickback ships a server implementation of the Agent Auth Protocol via @better-auth/agent-auth. Enable with auth.plugins.agentAuth: true and the compiler emits the full agent-auth surface: discovery at /.well-known/agent-configuration, capability projection from the project's OpenAPI spec, a device-authorization approval page in the Account SPA at /agents/approve, and the four agent-auth tables added to the auth schema automatically.
OpenAPI capability projection runs by default. Every operation with an operationId becomes an agent capability. Default approvalStrength map: GET → "session", POST/PUT/PATCH/DELETE → "webauthn". The proxy onExecute makes a self-fetch with a Better Auth JWT minted for the agent session, so the inner request hits the existing JWT fast-path and runs the full firewall + access + masking pipeline. Opt out of OpenAPI projection with agentAuth: { fromOpenAPI: false } and wire capabilities + onExecute by hand.
Bearer-only on /mcp and the agent-auth surface
/mcp forwards only Authorization: Bearer … into the inner request. Cookie and x-api-key forwarding are removed. Three Bearer flavors land on the same header — Better Auth session token (via the bearer plugin), OAuth access token (via OAuth Provider), and agent JWT (via @better-auth/agent-auth).
Auth DSL gains first-class plugin entries
bearer, oauthProvider, and agentAuth are now typed entries in both the array-form union and the BetterAuthPlugins interface, with their own options shapes (OAuthProviderOptions, AgentAuthOptions). Existing as any casts can come out.
Auto-enable rule: turning on agentAuth force-adds bearer + oauthProvider. No useful agent-auth deployment ships without those two.
queue-consumer.ts gating
The queue consumer is emitted when any of the following are true: embeddings are configured, queue handlers exist under services/queues/*, or additionalQueues is declared. Previously the third case left projects with a wrangler binding pointing at a missing handler.
v0.15.0 — May 7, 2026
Sysadmin tier for the CMS
A new opt-in cms: { sysadmin: true } flag turns the CMS into a cross-tenant DB-admin tool gated by user.role === 'sysadmin'. Distinct from the Better Auth 'admin' plugin role, so user-management powers and DB-admin powers stay decoupled. Off by default.
When enabled, the compiler emits a SYSADMIN pseudo-role (a fifth marker alongside PUBLIC / AUTHENTICATED / USER / ADMIN), a firewall escape hatch that drops tenant scopes for sysadmins while preserving soft-delete, a true cross-tenant /admin/v1/sysadmin/organizations endpoint, and a relaxed cross-tenant guard for list routes.
The CMS SPA gains a two-mode design: org admins keep the existing membership-based switcher, sysadmins get an "All organizations" selector that pins tenants via ?organizationId= on every API call.
Durable Object [[migrations]] order is preserved
Cloudflare's migration list is append-only. The compiler now reads your existing wrangler.toml (when present) and emits auto-generated DO [[migrations]] blocks in two phases: deployed entries first (preserving order), net-new entries appended last. Reordering DOs in the config array no longer reshuffles deployed migrations.
quickback/public/ — static-asset passthrough
Anything dropped under quickback/public/ is copied verbatim into src/apps/ on every compile and served by the wrangler [assets] binding. Use for favicons, robots.txt, OG images, .well-known/ files, marketing PDFs — anything that needs to ride past the compiler as bytes.
- Optional. Missing folder is a no-op.
- Stale-cleaned. Files removed from
quickback/public/disappear on the next compile. - Hidden entries (
.DS_Store, dotfiles) are skipped. - Collisions with compiler-emitted manifest paths fail the compile loudly.
Minor
- Standalone-action wrapper skips the org gate when the access tree references any pseudo-role.
- Queue handler emit no longer drops the
queueexport when there are no embeddings.
v0.14.7 — May 7, 2026
Codegen fixes for projects with non-id primary keys and DO-only features
Four surgical fixes to the generated output:
- Duplicate table imports in relationship-role routes are deduplicated.
dbreferences in single-record and bulk action handlers now resolve correctly during pre-fetch.- PartyServer auth gates pass the in-scope
dbhandle into firewall calls. - Durable-object-only features keep their
lib/files in the output.
emailOtp + AWS SES is OTP-only
auth.emailOtp with the AWS SES email provider no longer auto-wires the magic-link companion. Mail-client URL prefetching can burn the one-time code before the human sees it; the Account SPA's /email-otp route is the single canonical entry point. Projects that explicitly want the magic-link arm can wire sendVerificationOTP directly.
v0.14.6 — May 7, 2026
MCP transport correctness
Four fixes to the MCP-over-HTTP path:
tools/callURLs are no longer double-prefixed for action tools.- Auth middleware accepts API keys via
Authorization: Bearerin addition tox-api-keyand query parameters — the channel MCP clients use exclusively. auth.roleHierarchyis expanded for runtime role checks across api-key, JWT, and session auth paths. A higher role inherits the privileges of every lower role.- Team-scoped firewall predicates degrade gracefully when no team is active (org scoping continues to apply; team scoping is optional).
v0.14.5 — May 7, 2026
MCP environment threading
The generated MCP server's tools/call dispatch threads env and executionCtx into the inner request, so action tools that touch Cloudflare bindings (D1, KV, R2, Queues) no longer crash with undefined env access.
v0.14.4 — May 7, 2026
via: firewall predicates pull required imports
The relationship-role generator emits inArray and sql imports when the firewall has any via: predicate, and buildFirewallConditions accepts (ctx, db) so the relationship subquery has a Drizzle client to evaluate against. Every CRUD and action call site passes db through automatically.
v0.14.3 — May 7, 2026
Relationship-role table re-exports
Generated <table>.resource.ts files now re-export their Drizzle table binding. Cross-feature relationship subqueries that import the binding from the resource file (rather than the schema) resolve correctly.
v0.14.2 — May 7, 2026
Historical DO [[migrations]] are preserved
A new bindings.durableObjectMigrations config slot carries forward deleted_classes, renamed_classes, and transferred_classes lifecycle entries across recompiles. Cloudflare's migration list is append-only; entries declared here are emitted first in wrangler.toml, then auto-generated current-class entries follow:
bindings: {
durableObjects: [{ name: 'NOTEPAD', className: 'Notepad', from: 'lib/Notepad' }],
durableObjectMigrations: [
{ tag: 'qb-do-deleted-legacy-notes-2026-05-05', deletedClasses: ['LegacyNotes'] },
],
}All four lifecycle shapes are supported. User-declared entries can override auto-emit by reusing the same tag.
v0.14.1 — May 7, 2026
Bulk action variants in openapi.json
Record-based actions with bulkVariant: true are now emitted as sibling operations in the OpenAPI spec, with the documented { ids, input, failFast? } request shape, 200/207/400 responses, and a separate <Resource><Action>Bulk operationId so typed-client codegen distinguishes single from bulk.
Cross-tenant "my events" patterns
The access docs now include a worked example for the cross-tenant aggregation pattern (e.g. GET /my-events returning resources across all tenants a caller is invited to). See Access → Cross-tenant dashboards.
v0.14.0 — May 7, 2026
Relationship-based authorization
Roles can now be bound to a domain-table membership ("user is a confirmed guest of this event") rather than an org membership or a pseudo-role. Declare the relationship once and reference it from firewall: or access.roles::
authz: {
relationships: {
guestOf: {
from: 'event_guests',
subject: { column: 'userId', equals: 'ctx.userId' },
resource: { column: 'eventId' },
where: { status: 'confirmed' },
},
},
roles: {
guest: { via: 'guestOf' },
eventStaff: { or: [{ via: 'guestOf' }, { roles: ['admin', 'owner'] }] },
},
}
// features/sessions/sessions.ts
firewall: [
{ field: 'organizationId', equals: 'ctx.activeOrgId' },
{ field: 'eventId', via: 'guestOf' },
],
read: { access: { roles: ['admin', 'member', 'guest'] } },
create: { access: { roles: ['admin', 'guest'] } },
update: { access: { roles: ['admin'] } },The compiler emits an inline Drizzle subquery against the relationship table and AND-composes it as the outermost firewall clause. The relationship table's own firewall (tenant scope, soft-delete) is honored inside the subquery; cross-tenant grants are impossible because the resource's tenant filter sits outermost.
Supported surfaces: row-level scoping via firewall: [{ field, via }], role gates on writes and reads, view access. Masking and action access reject relationship-roles at compile time with pointers to the working pattern (gate the row at the firewall layer, gate the role at the access layer).
Cross-tenant aggregation dashboards aren't solved by relationship-roles — the tenant firewall remains the outermost AND. For that pattern, use a user-scoped firewall or function-form access on a dedicated route.
See Firewall → Relationship-based scoping and Access → Relationship-based authorization.
Bulk variants on record-based actions
defineAction({ bulkVariant: true }) emits POST /:resource/batch/{action} alongside the per-record route. The bulk handler loops the same access + transition + execute pipeline over a { ids, input, failFast? } body:
export default defineAction({
description: "Move an application forward in the pipeline.",
input: z.object({ nextStatus: z.enum(["screening", "interview", "offer"]) }),
access: { roles: ["owner", "admin"] },
bulkVariant: true,
transition: { field: "status", via: "nextStatus", fromTo: { /* ... */ } },
async execute({ db, record, input, whereTransition }) {
// exact same body — runs once per id
},
});Per-record outcomes return as 207 Multi-Status. failFast: true aborts on the first failure and wraps the loop in db.transaction() on tx-supporting providers; D1 fails fast without rollback. Max 100 IDs per request. Rejected on standalone actions and unsafe actions.
Aggregations on read views
Read views can declare pre-computed metrics that surface alongside the row payload:
read: {
views: {
pipeline: {
fields: ["id", "jobId", "candidateId", "status", "appliedAt"],
aggregations: {
totalApplications: { fn: "count" },
byStatus: { fn: "groupBy", field: "status" },
},
},
},
}Aggregations run over the same firewalled, filtered set as the row query; pagination is intentionally ignored. Supported functions: sum, avg, min, max, count, count_distinct, groupBy. See Views → Aggregations.
v0.13.5 — May 6, 2026
Object-form auth.plugins config is honored
The documented plugins: { oauthProvider: {...} }, plugins: { organization: { teams: { enabled: true } } } shapes now flow through the compiler unchanged. Per-plugin options reach the generated auth.ts as written.
roles: ["PUBLIC"] allowed on writes and actions
PUBLIC is the explicit, uppercase opt-in for unauthenticated access on every access node — reads, writes, views, and actions. The runtime trusts the marker; row-level safety comes from firewall, masking, and the action's own validation. PUBLIC actions continue to receive mandatory audit logging.
Common pattern: pair roles: ["PUBLIC"] with firewall: { exception: true } on the resource so the row predicate doesn't reject every unauthenticated caller.
v0.12.0 — May 5, 2026
Per-room authorization for PartyServer
bindings.durableObjects[].access declares a path-scoped gate that runs the declared resource's firewall and read.access pipeline before the WebSocket upgrade:
durableObjects: [
// Single-resource: roomId IS the chat row id.
{ name: 'Chat', className: 'Chat', from: 'lib/Chat',
access: { resource: 'chats' } },
// Multi-resource: regex dispatches kind → resource.
{ name: 'Notepad', className: 'Notepad', from: 'lib/Notepad',
access: {
roomId: { pattern: '^(day-note|interaction|record):([A-Za-z0-9_-]+)$', kindGroup: 1, idGroup: 2 },
resources: {
'day-note': { resource: 'day-notes' },
'interaction': { resource: 'interactions' },
'record': { resource: 'records' },
},
} },
// Anonymous lobbies / public pads: explicit opt-out.
{ name: 'PublicLobby', className: 'PublicLobby', from: 'lib/PublicLobby',
access: 'public' },
]The gate 401s unauthenticated callers, 403s when the firewall hides the row, 404s when the row is missing. Resource references are validated against feature names at compile time. See PartyServer Rooms → Per-room authorization.
v0.11.1 — May 5, 2026
PartyServer auto-mount is secure by default
The /realtime/* auto-mount now wraps partyserverMiddleware in an auth check. Unauthenticated upgrades return 401; the four auth mechanisms (session cookie, JWT bearer, x-api-key header, query parameter) all unlock connections. Per-room access remains the Server class's responsibility in onConnect.
v0.11.0 — May 5, 2026
OAuth Provider (MCP-ready)
Quickback can act as a standards-compliant OAuth 2.1 + OIDC authorization server via @better-auth/oauth-provider. Enable with plugins: ["oauthProvider"]. The compiler auto-includes Better Auth's jwt plugin and the Account SPA gains a /consent page for the standard authorization-code flow.
This replaces Better Auth's deprecated mcp plugin and gives every MCP client (Cursor, Claude Desktop, claude.ai connectors, custom tooling) RFC 7591 dynamic client registration plus the discovery documents they need to bootstrap. See Auth Plugins → OAuth Provider.
API keys via query parameter
The auth middleware now accepts API keys as ?api_key=… or ?key=… query parameters. The x-api-key header takes precedence when both are sent. Prefer the header whenever the client supports it — query parameters land in access logs and Referer headers.
PartyServer rooms + realtime URL split
The realtime URL space is reorganized so names match what each primitive does:
/realtime/v1/*→/broadcast/v1/*— the org-wide Broadcaster moves to its semantically accurate prefix./realtime/<binding>/<room-id>— new mount for PartyServer rooms. Per-room Durable Objects with presence and lifecycle hooks. Auto-mounted whenever a project declares any in-worker DO.
Breaking — recompile + redeploy required:
REALTIME_URLenv var renamed toBROADCAST_URL.- SPA helpers
getRealtimeWsUrl()/getRealtimeTicketUrl()renamed togetBroadcastWsUrl()/getBroadcastTicketUrl(). appConfig.routes.api.realtimerenamed toappConfig.routes.api.broadcast.
partyserver, partysocket, and hono-party are pre-installed in the compiler image; collab projects compile without an extra npm install step.
v0.10.20 — May 4, 2026
In-worker Durable Objects
bindings.durableObjects[].from flips a DO binding into in-worker mode and emits the export, the [[migrations]] block, and the CloudflareBindings type all together:
durableObjects: [{
name: "ITEM_STREAM",
className: "ItemStream",
from: "features/loops/lib/ItemStream",
}]DO class files live under quickback/lib/ or quickback/features/<feature>/lib/ — both copy verbatim into the generated src/ tree on every compile. from and scriptName are mutually exclusive; cross-worker DOs (scriptName set) skip the re-export and migration block.
migrationTag (default qb-do-${className}) is stable across regenerations and must never be removed or renamed once deployed. useSqlite defaults to true. See Bindings → Durable Objects.
v0.10.18 — May 2, 2026
experimentalRemote — share deployed D1 with wrangler dev
Per-binding experimental_remote = true lets wrangler dev read/write deployed D1 for selected bindings:
providers: {
database: defineDatabase("cloudflare-d1", {
splitDatabases: true,
experimentalRemote: { auth: true, features: false },
}),
}Useful for "share prod auth in dev, keep feature writes sandboxed." Off by default.
Google OAuth + signup ToS
The Account SPA wires Google OAuth end-to-end when configured under auth.config.socialProviders.google.enabled. The signup flow gains a Terms-of-Service checkbox gate (configurable via the Account SPA branding).
CLI compile auth gate
quickback compile now requires authentication for cloud-compiler runs. Device-flow ?redirect= parameters are standardized across the login + signup pages so a user clicking "Create one" no longer loses their return URL.
v0.10.15–v0.10.17 — May 2, 2026
CSP auto-config
When security.csp is set, the compiler widens directives additively based on the project's enabled features:
| Trigger | Auto-added | Directive |
|---|---|---|
cms: true OR account: true | 'unsafe-inline', https://fonts.googleapis.com | styleSrc |
cms: true OR account: true | https://fonts.gstatic.com | fontSrc |
csp set (always) | @trustedOrigins | connectSrc |
| Bundled SPA enabled | SPA-baked API origin (config.domain, quickback.{baseDomain}, cms.domain, account.domain) | connectSrc |
Anything you already wrote stays — auto-config never removes or overrides. Each applied addition is logged to stderr. Opt out with security: { cspAutoConfig: false }.
Most projects can write the minimal:
security: {
csp: {
defaultSrc: ['@self'],
scriptSrc: ['@self'],
objectSrc: ['@none'],
upgradeInsecureRequests: true,
},
}…and the compiler fills in font hosts, trusted origins, and SPA-baked URLs.
When a target directive is missing, auto-config scaffolds it from default-src baseline rather than widening default-src itself — CSP3 fall-back semantics would otherwise leak the addition to every directive that falls back to default-src.
v0.10.14 — May 1, 2026
auth.jwt config
The bearer-token JWT used by the auth fast-path is configurable. Tune expiresIn to bound the post-revocation replay window without affecting browser SPAs (which auto-refresh on every authed call):
auth: {
jwt: {
expiresIn: 30,
issuer: 'my-api',
audience: 'partner-svc',
secretEnv: 'CUSTOM_JWT_SECRET',
},
}Admin MCP endpoints filtered
Better Auth admin endpoints (/admin/list-users, /admin/ban-user, etc.) now carry x-access: { userRole: ['admin'] } in the generated OpenAPI spec. The MCP tools/list access-filter drops them for non-admin callers.
Declarative security config
Every security response header (Content-Security-Policy, COOP, COEP, CORP, HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy) is configurable from quickback.config.ts. @-prefixed sentinels (@self, @none, @unsafe-inline, @trustedOrigins, etc.) replace awkward "'self'" quoting.
The compiler lints the resolved config for known footguns: @unsafe-inline in script-src, wildcard sources, missing default-src, HSTS preload-list misconfiguration.
HSTS skipped on localhost
Generated middleware skips Strict-Transport-Security when the request hostname is localhost, 127.0.0.1, or *.localhost. Without this gate, wrangler dev would pin a developer's browser to HTTPS across every project on that machine.
v0.10.13 — May 1, 2026
Clickjacking defence on every Worker
Every Worker emits Content-Security-Policy: frame-ancestors 'self' <TRUSTED_ORIGINS…> and X-Frame-Options: SAMEORIGIN on every response. The CSP allowlist is sourced from the same TRUSTED_ORIGINS constant that gates CORS.
v0.10.11 — May 1, 2026
MCP tools/list is access-filtered
Each MCP tool now carries sanitised access metadata. tools/list filters against the caller's ctx: unauthenticated callers see only PUBLIC tools, authenticated callers see only role-gated tools they pass.
- Wildcard CORS removed from the MCP endpoint.
- Unauthenticated
GET /mcpno longer enumerates tool names. - Internal flavor text is removed;
annotations.destructiveHintand_unsaferemain — those are the spec-correct signals for clients.
tools/call continues to re-enter the Hono app and run every middleware (auth, firewall, access, guards, masking), so hiding a tool from list never weakens the underlying gate.
v0.10.10 — April 29, 2026
Reserved keys in access.record are rejected
access.record is a flat field map evaluated as implicit AND between entries — keys must be column names. The compiler now rejects reserved keys (and, or, roles, userRole) inside record with migration guidance pointing at the working pattern (top-level siblings with their own record: blocks).
v0.10.8 — April 29, 2026
Audit logging on Postgres targets
audit_events previously only existed on cloudflare-d1. The audit table now lives in a dedicated audit schema (drizzle.pgSchema('audit')) on Postgres targets. The hasSecurityAudit gate accepts D1 or any PG dialect; auditDatabaseId remains a D1-only requirement.
auditDatabaseId is a compile-time requirement
Projects with unsafe or PUBLIC actions that previously produced a placeholder database_id = "local-audit" in production now fail at compile time with the exact wrangler d1 create <project>-audit command and the config key where the returned ID goes.
Action input → OpenAPI / MCP
defineAction({ input: z.object({...}) }) schemas flow into the OpenAPI requestBody and the MCP tool's inputSchema.properties. Simple z.object shapes are extracted at compile time; discriminated unions, refinements, recursive schemas, and bare-identifier references run through Zod's native z.toJSONSchema() for full-fidelity output.
OpenAPI fidelity
- Response schemas emit
required[]for every non-nullable column. text(_, { enum: [...] })columns emit as closed{ type: "string", enum: [...] }.idclaimsformat: "uuid"only when the project'sgenerateIdactually produces UUIDs. cuid2 / prefixed / nanoid / serial generators emit plain{ type: "string" }.
Minor
- Auto-mask predicate verifies the resolved owner column exists before emitting an
or: 'owner'clause; falls back to roles-only with a warning otherwise. - Generated CRUD errors emit canonical
QuickbackErrorCodeenum values (DB_NOT_NULL_VIOLATION,DB_UNIQUE_VIOLATION, etc.) so generated SDKs can exhaustively switch oncode. See API errors.
DSL: one file per action, multi-table per feature
One file per action
Action authoring is one file per action under <feature>/actions/<name>.ts. The filename is the action name and the URL segment:
// features/applications/actions/advance.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";
export default defineAction({
description: "Move an application forward in the pipeline",
input: z.object({ nextStatus: z.enum(["screening", "interview", "offer"]) }),
access: { roles: ["owner", "admin"] },
transition: {
field: "status",
via: "nextStatus",
fromTo: { applied: ["screening"], screening: ["interview"], interview: ["offer"] },
},
async execute({ db, record, input, auditFields, whereTransition }) { /* … */ },
});defineAction is imported from a generated per-feature helper at <feature>/.quickback/define-action.ts — record, input, and audit fields are typed without manual annotations.
Discovery rules:
actions/<name>.tsbinds to the feature's primary table.actions/<table>/<name>.tsbinds to the sibling table file.- Setting
path:makes the action standalone (custom URL, no record fetch). - Tableless features require every action to be standalone.
Retired (rejected at load time with migration guidance): actions.ts (bundled multi-action file), _feature.ts, handlers/ directory + handler: string ref, defineActions(table, {…}), colocated defineActions inside table files, standalone: true flag, *-actions.ts / *.actions.ts filename patterns.
See Actions docs.
Multi-table actions per feature
One feature directory can own actions on multiple tables. Three discovery modes:
- Colocated (canonical) —
<table>.tscontaining bothdefineTableanddefineActions(<table>, …). One file per entity. actions.ts— single primary-table actions file at feature root.actions/<table>.ts— per-table action files for strict schema/behavior separation.
Plus a universal _feature.ts slot for non-table-bound feature-level actions and shared Zod schemas.
Action names are unique per (bindTable, name) pair. Sibling-bound actions/episodes/publish.ts and actions/shows/publish.ts mount at /episodes/:id/publish and /shows/:id/publish and compile cleanly.
Firewall: named-scope shape
The named-scope firewall shape is a co-equal user-facing input alongside the predicate array:
firewall: { organization: { column: 'tenant_id' } } // named-scope (compact)
firewall: { owner: { column: 'account_user_id' } }
firewall: { exception: true }
firewall: [
{ field: 'organizationId', equals: 'ctx.activeOrgId' },
{ field: 'deletedAt', isNull: true },
] // predicate array (explicit)Pick the form that reads better — named-scope for the common one-line case, predicate array when you need custom in-lists or non-scope predicates.
Auto-detection synonyms. When firewall: is omitted, the compiler scans the schema for an isolation column. The org-scope list accepts organizationId, organisationId, orgId, organization, organisation, org (American + British). User-scope accepts userId.
ownerId is a targeted error signal. When a table carries ownerId but no isolation column and no explicit firewall:, the compiler raises a specific error — ownerId is business-logic ownership, not access control. Use userId for access control, declare an explicit firewall:, or add a separate isolation column.
v0.10.0 — April 26, 2026
q.scope() — single-declaration tenant scoping
q.scope(kind) replaces the three-place pattern (column declaration + firewall predicate + ownership-guard) with one column:
columns: {
organizationId: q.scope('organization'), // → ctx.activeOrgId
ownerId: q.scope('owner'), // → ctx.userId
teamId: q.scope('team'), // → ctx.activeTeamId
}q.scope() columns auto-derive the firewall predicate, register with the guards layer as systemManaged (client input rejected), and auto-populate from ctx on create / upsert. The systemManaged extension generalises beyond q.scope(): any firewall predicate whose equals references ctx.* registers its field automatically.
See q.scope().
masking[col].query.roles — unified filter / sort / search gate
The three legacy per-capability flags (filterable, searchable, sortable) are replaced by a single query.roles allowlist:
masking: {
email: {
type: 'email',
show: { roles: ['admin'] },
query: { roles: ['admin'] }, // defaults to show.roles when omitted
},
}A view's own query.{filterable,searchable,sortable} allowlist is the opt-in for higher-privileged roles; the existing intersection validator gates it at compile time.
Breaking: masking[col].filterable / .searchable / .sortable is rejected at compile time with a migration hint. Replace with query.roles (or delete the flags — query defaults to show.roles).
v0.9.0 — April 25, 2026
Unified read: pipeline
The read side of every resource has its own block — read: — that owns the collection-level GET / endpoint, single-record GET /:id, and named view projections.
Breaking changes (compile-time errors with migration hints):
crud.list→read.access(plusread.pageSize/read.maxPageSize)crud.get→read.access(single-record GET inherits automatically)- Top-level
views: {...}→read.views: {...} crud.list.fields/crud.get.fields→ declare a named view underread.viewsand call/views/{name}
Pre-record access ordering
Handlers on /:id run a role-only access check before the firewall query. A caller without a matching role gets 403 before the database is touched. This closes the 404-vs-403 ID-probe channel — an unauthorized caller can no longer distinguish "row exists in another tenant" from "row doesn't exist." See Access evaluation order.
Transactional batch operations
Batch endpoints expose failFast and a meta.transactional response field. On tx-supporting providers (postgres, supabase, neon, libsql, better-sqlite3, bun-sqlite), failFast: true wraps the per-record loop in db.transaction(...) so any failure rolls back the entire batch. On Cloudflare D1 (no db.transaction), failFast: true stops on the first error but prior writes stay committed.
Embedding queue messages and realtime broadcasts emit after the transaction commits — rolled-back batches produce no orphan jobs.
See Batch Operations → Transactional semantics.
v0.8.7 — April 17, 2026
Hono CVE bump
Hono is bumped to ^4.12.14 across the monorepo, the compiler Docker image's pre-installed deps, and every generator path that emits Hono into compiled projects. Older ranges covered the vulnerable window. Recompile to pick up the fix in your generated project's package.json.
Cloudflare Email Sending in public beta
Quickback defaults to the Workers send_email binding for Cloudflare deploys. Cloudflare Email Sending has graduated to public beta — production-ready with zero-config. AWS SES remains a fully supported opt-in (email.provider: 'aws-ses') for non-Cloudflare runtimes. See Email Configuration.
Dependency sweep
wrangler@^4.83.0, drizzle-orm@^0.45.2, drizzle-kit@^0.31.10, zod@^4.3.6, @cloudflare/workers-types@^4.20260417.1.
v0.8.5 — April 15, 2026
userRole access primitive
A new userRole?: string[] field in the declarative Access config evaluates against ctx.userRole (the Better Auth admin-plugin user.role column). Distinct from roles, which matches ctx.roles (org membership).
// Org owner OR platform admin
access: { or: [{ roles: ['owner'] }, { userRole: ['admin'] }] }
// Platform admins see SSNs across every org
masking: { ssn: { type: 'ssn', show: { userRole: ['admin'] } } }userRole works everywhere Access is accepted — CRUD, actions, views, masking, and inside or / and combinators. See Access → userRole vs roles.
CMS admin gate enforced server-side
cms: { access: "admin" } previously only hid the CMS link in Account UI and rendered an "Access Denied" screen inside the SPA. Server-side enforcement now applies at every CMS-facing surface: the SPA shell, /api/v1/schema, and custom_view CRUD.
Better Auth 1.5 + @cloudflare/workers-types compatibility
Generated API code typechecks against the new versions. The org-member role-change broadcast hook is emitted via organizationHooks.afterUpdateMemberRole (the supported shape after the 1.5 deprecation of databaseHooks.member.update.after). REALTIME_URL and ACCESS_TOKEN are always emitted as optional bindings when the organization plugin is active.
v0.8.4 — April 14, 2026
generateId: "short" strategy
A compact ID option for tables where short, shareable codes matter more than collision resistance at scale:
providers: {
database: defineDatabase("cloudflare-d1", { generateId: "short" }),
}Generates 6-character alphanumeric IDs (e.g. a3F9xK) using nanoid's customAlphabet with the full 62-char alphabet (0-9A-Za-z). Requires the nanoid package.
When to use: room codes, invite links, share URLs, ephemeral records.
When not to use: high-volume tables. 62^6 ≈ 56.8B combinations means ~238K rows before a 50% collision probability. For large tables stick with "uuid", "cuid", or "prefixed". See Providers → ID Generation Options.
v0.8.2 — April 11, 2026
Catch-all VITE_QUICKBACK_URL
A single Quickback origin can be set in place of four separate URL env vars:
VITE_QUICKBACK_URL=https://secure.example.com| Individual var (explicit) | Fallback when unset |
|---|---|
VITE_QUICKBACK_API_URL | ${VITE_QUICKBACK_URL} |
VITE_QUICKBACK_ACCOUNT_URL | ${VITE_QUICKBACK_URL}/account |
VITE_QUICKBACK_CMS_URL | ${VITE_QUICKBACK_URL}/cms |
VITE_QUICKBACK_APP_URL stays independent — it points at the tenant's own frontend. Non-breaking; individual vars take precedence when set. Library users can set __QUICKBACK_URL__ on globalThis. See Environment Variables.
v0.8.1 — April 9, 2026
Role hierarchy with + suffix
Define a hierarchy and use + to mean "this role and above":
auth: { roleHierarchy: ['member', 'admin', 'owner'] }
crud: {
list: { access: { roles: ["member+"] } }, // member, admin, owner
create: { access: { roles: ["admin+"] } }, // admin, owner
delete: { access: { roles: ["owner"] } }, // owner only
}Roles are expanded at compile time. No + = exact match. See Access → Role Hierarchy.
v0.8.0 — April 9, 2026
Breaking: VITE URL environment variables namespaced
All Quickback-generated URL environment variables are namespaced under VITE_QUICKBACK_* to avoid collisions with other Vite projects.
| Old | New |
|---|---|
VITE_API_URL | VITE_QUICKBACK_API_URL |
VITE_ACCOUNT_URL | VITE_QUICKBACK_ACCOUNT_URL |
VITE_APP_URL | VITE_QUICKBACK_APP_URL |
VITE_CMS_URL | VITE_QUICKBACK_CMS_URL |
Non-URL variables are unchanged (VITE_ENABLE_*, branding vars, VITE_STRIPE_PUBLISHABLE_KEY).
Action required:
- Compiled projects: re-run
quickback compile. - Standalone deploys: update
.env,.env.production, andwrangler.tomlfiles manually. - Library users:
__QUICKBACK_API_URL__/__QUICKBACK_APP_URL__globals are unchanged.
CMS access control
cms.access: "admin" enforces access at the CMS level, not just link visibility. Non-admin users (user.role !== "admin") see an "Access Denied" screen with a sign-out button.
v0.5.11 — February 24, 2026
Mandatory unsafe action audit trail
Structured unsafe config (unsafe: { reason, adminOnly, crossTenant, targetScope }). Cross-tenant unsafe actions require Better Auth authentication plus platform admin role (ctx.userRole === "admin"). Mandatory audit logging on success, denial, and error paths. Cloudflare output includes AUDIT_DB wiring, drizzle.audit.config.ts, and db:migrate:audit:* scripts when unsafe actions are present.
Compile-time raw SQL guard for actions and handlers (allowRawSql: true required per action).
Security contract report artifacts
Generated security contract artifacts: reports/security-contracts.report.json, reports/security-contracts.report.sig.json. Config-driven signing controls (compiler.securityContracts.report.signature.*). Missing required signing keys fail loudly with explicit remediation.
Headless Drizzle rename hints
compiler.migrations.renames configuration for CI/CD environments where Drizzle's interactive rename prompts block compilation. See Configuration.
Minor
- Email OTP magic links work correctly.
- Auth is required for all API routes when running locally.
- Single-file multi-table exports alongside
defineTable()produce a clear error.
v0.5.8 — February 14, 2026
Quickback CMS
A schema-driven admin panel that connects to your generated API. The compiler now outputs a JSON schema registry consumed by the CMS at runtime. Firewall error modes (reveal for 403 with details, hide for opaque 404) are configurable per resource for security-sensitive deployments.
v0.5.7 — February 12, 2026
Scoped database for actions
Actions receive a security-scoped database instead of a raw Drizzle instance. The compiler generates a proxy wrapper that automatically enforces org isolation, owner filtering, and soft-delete visibility — the same protections that CRUD routes have always had.
Duck-typed column detection at runtime:
| Column Detected | SELECT / UPDATE / DELETE | INSERT |
|---|---|---|
organizationId | Adds WHERE organizationId = ? | Auto-injects organizationId from context |
ownerId | Adds WHERE ownerId = ? | Auto-injects ownerId from context |
deletedAt | Adds WHERE deletedAt IS NULL | — |
Every action is secure by default — no manual WHERE clauses needed.
Actions that intentionally need to bypass security (admin reports, cross-org queries, migrations) can declare unsafe: true to receive a raw, unscoped database handle via rawDb:
defineActions(analytics, {
globalReport: {
unsafe: true,
execute: async ({ db, rawDb, ctx, input }) => {
// db → still scoped (safety net)
// rawDb → bypasses all security filters
const allOrgs = await rawDb.select().from(organizations);
},
},
});Without unsafe: true, rawDb is undefined. See Actions.
Cascading soft delete
Soft-deleting a parent record automatically cascades to child and junction tables within the same feature. The compiler detects foreign key references at build time and generates cascade UPDATE statements.
Rules:
- Applies to soft delete only. Hard delete relies on database-level
ON DELETE CASCADE. - Cascades within the same feature only — cross-feature references are not affected.
- Child tables must have
deletedAt/deletedBycolumns (auto-added by the compiler).
See Actions API → Cascading Soft Delete.
Advanced query parameters
New capabilities for all list endpoints:
- Field selection —
?fields=id,name,status - Multi-sort —
?sort=status:asc,createdAt:desc - Total count —
?count=truereturns total matching records inX-Total-Count - Full-text search —
?search=keywordsearches across all text columns
See Query Parameters.
v0.5.5 — February 5, 2026
OpenAPI spec generation
Generated APIs include a full OpenAPI specification at /openapi.json. Better Auth endpoints are included in the spec.
Better Auth plugins
Published @quickback-dev/better-auth-upgrade-anonymous (post-passkey email collection flow), @quickback-dev/better-auth-combo-auth (combined email + password + OTP), and @quickback-dev/better-auth-aws-ses (AWS SES email provider).
Security headers
Global error handler prevents leaking internal error details. Security headers middleware (CSP, HSTS, X-Frame-Options, X-Content-Type-Options). BETTER_AUTH_SECRET properly passed to generated config.
v0.5.4 — January 30, 2026
Account UI
Pre-built authentication UI deployed as Cloudflare Workers. Sessions, organizations, passkeys, passwordless, admin panel, API keys. Dual-mode: standalone (degit template) or embedded with Quickback projects.
Webhook system
Inbound webhook endpoints with signature verification. Outbound webhooks via Cloudflare Queues with automatic retries. Configurable per-feature webhook events.
Realtime & vector search
Durable Objects + WebSocket realtime subscriptions. Vector embeddings via Cloudflare Vectorize. KV and R2 storage integrations.
v0.5.0 — January 2026
Initial release
- Quickback Compiler — TypeScript-first backend compiler.
- Four security pillars — Firewall, Access, Guards, Masking.
defineTable()— schema + security configuration in a single file.- Templates — Cloudflare Workers, B2B SaaS.
- Cloud compiler — remote compilation via
compiler.quickback.dev. - CLI —
quickback create,quickback compile. - Better Auth integration — organizations, roles, sessions.
- Drizzle ORM — schema-first with automatic migrations.
- Cloudflare D1 — split database support (auth + features).