Quickback Docs

Changelog

Release notes and version history for the Quickback compiler, CLI, and platform.

v0.52.1 — July 17, 2026

CLI: cold-start tolerance for the cloud compiler

Right after a service deploy, the first compile pays a fresh container image pull — which routinely exceeds the CLI's old 60-second readiness budget and surfaced as Compiler container did not become ready in time even while /health was green. waitForReady now allows 4 minutes, reports elapsed warm-up progress, and the timeout message explains the deploy-in-progress case.

v0.52.0 — July 17, 2026

Five action primitives: afterCommit, realtime emit, transitions v2, refs, bundles

One release, five primitives that collapse the most-repeated hand-written patterns in real Quickback projects (measured across a 349-action production corpus). Every one is additive — existing projects compile unchanged, and plain { field, fromTo, to|via } transitions keep their emission byte-identical.

  • afterCommit hooks + effects.enqueue — named best-effort side effects that run after execute returns. Each hook is independently wrapped (failures log as [<action>] afterCommit:<hook> failed, never touching the response), non-blocking hooks ride executionCtx.waitUntil, { blocking: true } awaits before responding, and everything skips when execute throws or returns dryRun: true. The execute context also gains effects.enqueue(name, fn) for mid-execute captures and now — one ISO timestamp per invocation. Replaces the per-leg try/catch + waitUntil idiom. See After-commit hooks.

  • Realtime emit for custom actions — tables that lock auto-CRUD no longer hand-roll frames. realtime: { emitFromActions: true, scopeKeyFrom: "events:{eventId}" } makes scoped-db writes inside record-bound and bulk actions emit auto-CRUD-byte-compatible frames after commit (masking + audience applied at fanout; the fail-closed audience requirement extends to action emission). Actions can also declare emit: for a pre-bound emitCrud(row, oldRow?). Updates/deletes pre-select old rows on the same WHERE; raw unsafeDb writes stay invisible. See When auto-broadcast fires.

  • Transitions v2transition grows guard (equality / null / notNull / custom(record)), onIllegal { code, status, message }, idempotent: "noop", and stamp/clears that ride a compiler-applied, TOCTOU-safe UPDATE (guards fold into the WHERE; a lost race is 409 ACCESS_TRANSITION_LOST). With an applied write, execute becomes optional. Table-level transitions: { publish: { field, from, to, access } } generates the action files — including undo: inverses and onEnter cascades — mounted exactly where hand-written files would be (explicit files win, with a warning). The ten byte-identical publish/revert files in the podcast fixture become two lines per entity. See Transitions.

  • refs — scoped foreign-key inputs — the "load the id, 404 if missing, assert it belongs to this event" preamble becomes declarative. Loads run through the scoped db (a cross-org id 404s by construction), batch into one Promise.all, assert matchScope / cross-input matchWith parenthood, and inject rows under their as names. Ref keys are validated against the inline z.object input (with agreeing optionality) at compile time. See Refs.

  • defineBundle — single-round-trip bootstrap reads — declarative list() slices with the firewall, the table's real mask functions, and { data } cache-seed envelopes; custom() escape-hatch sections; a SHA-1 ETag over { scopeSeed, userId, payload } with the ifNoneMatch → notModified short-circuit; and Server-Timing. Fail-closed: a bundle's roles must be a subset of every list table's read.access roles — a bundle can never widen read access. The digest ships as sha1Hex in lib/etag so hand-written reads can delete their copies. See Bundles.

Supporting changes: ActionError / TransitionLostError are re-exported from the generated .quickback/define-action helpers (routes duck-type on error.name, so project-local copies keep working); sendQueueBatched in lib/effects chunks queue sends at the Cloudflare 100-message cap; and the $record.* / $ctx.* substitution grammar used by onEnter and bundle where is one strict allowlist, always parameterized — never interpolated into SQL.

CLI device login fixed: /cli/authorize now claims the code before approving

Better Auth's deviceAuthorization plugin requires the verifying session to claim a device code (GET /device?user_code=…) before it accepts an approve or deny. The account SPA's /cli/authorize page posted the approval directly, so every quickback login failed with "Device code has not been claimed by a verifying session." The page now claims first, then approves — and surfaces claim failures (expired or mistyped codes) with the server's message.

Generated builds no longer rewrite the auth schema

Generated Better Auth projects now run tsc directly from npm run build. quickback compile already generates, qualifies, and envelopes src/auth/schema.ts; invoking auth:schema again during every build rewrote that compiler-owned artifact and made an otherwise clean release worktree dirty. The explicit auth:schema diagnostic command remains available, while normal build, Wrangler, and deployment preflight paths are source-read-only. When recompiling an existing project, compiler-owned package.json script names now converge to their current generated commands while unrelated custom scripts are preserved. This upgrades projects whose previous generated build script still invoked auth:schema.

Non-cross-tenant unsafe actions preserve delegated RLS claims

Generated Neon handlers now reserve createServiceDb for actions that explicitly enable unsafe.crossTenant. An action that enables raw database access with crossTenant: false keeps the request database, so organization and delegated-principal claims survive into Hyperdrive transactions instead of being cleared by the service-role preamble. True cross-tenant sysadmin actions retain their audited RLS bypass.

Neon Hyperdrive guidance now also calls out Postgres' SELECT … FOR UPDATE authorization rule: a locked row must pass both SELECT and UPDATE policies. Delegated actions should use ordinary selects for read-only eligibility tables and lock only mutable rows they are authorized to update. A PGlite regression proves that ordinary delegated reads remain visible while the generated restrictive update fence blocks an unauthorized row lock.

Atomic email-OTP auth routing no longer recurses on ordinary requests

The generated atomic email-OTP wrapper now captures and binds Better Auth's original request handler before replacing the public handler property. Session reads and every other non-atomic auth route therefore forward to Better Auth exactly once instead of re-entering the wrapper until the Worker exhausts its call stack. The exact email-OTP sign-in route retains its transactional admission and rollback behavior.

Generated Better Auth CLI dependencies no longer retain vulnerable Lodash

Better Auth projects now emit a root npm override for lodash@4.18.1. This keeps the supported stable auth@1.6.x CLI and its better-auth executable, while lifting the CLI's dev-only Prisma parser chain off Lodash releases affected by the 2026 code-injection and prototype-pollution advisories. Runtime Better Auth packages and generated authentication behavior are unchanged.

The CLI now merges generated npm overrides explicitly when recompiling an existing project: unrelated user overrides remain, while compiler-owned security pins win a conflict. Recompile and reinstall dependencies to refresh the project lockfile. npm audit --omit=dev remains the production dependency view; upstream development-server advisories may still appear separately.

Postgres constraint replacements apply in dependency-safe order

Neon migration post-processing now orders both halves of a Drizzle constraint replacement. A dependent foreign key is dropped before its referenced unique or primary-key constraint, then the replacement key is added before the foreign key that consumes it. This prevents Postgres 2BP01 failures when Drizzle emits the parent key drop first, while preserving byte-identical output for already-correct migrations and the relative order of unrelated statements.

Schema registry output is byte-reproducible

schema-registry.json no longer includes the wall-clock generatedAt field. Its remaining metadata and schema content are derived entirely from compiler inputs, so consecutive compiles of identical logical input now produce the same registry bytes. This makes artifact hashes, caches, and deployment diffs stable without timestamp normalization.

Generated auth and discovery surfaces now remain truthful and type-safe

Atomic email-OTP generation now imports and invokes before-email-otp-activate.ts exactly once and preserves the transaction's real Drizzle select capability in the hook context. Strict generated TypeScript therefore accepts both the base Neon service database and its interactive transaction without erasing the hook's read contract.

Contract-v2 projects that omit auth.jwt no longer advertise the disabled Quickback JWT fast path in llms.txt or RFC 8414 metadata. Bearer-JWT guidance, the fallback /api/v2/token endpoint, and JWT scopes are emitted only when the corresponding JWT surface exists; real Better Auth OAuth-provider discovery is unchanged.

Cloudflare Email callbacks now all enter the shared credential-delivery scheduler exactly once. The scheduler anchors the observed rejection with waitUntil, emits the metadata-only auth.credential_delivery_failed event, and never leaks the provider error. Generated CRUD routes also keep delegated principal and account authority disjoint: ?organizationId= is rejected for a delegated principal instead of synthesizing activeOrgId on its context. This closes both the authority ambiguity and the generated AppContext union type errors.

Email-OTP activation can be admitted atomically in the API layer

Contract-v2 Cloudflare projects using one Neon Hyperdrive database can opt the exact Better Auth POST /sign-in/email-otp handler into an interactive transaction with atomicAuthRoutes: ["emailOtpSignIn"]. The paired recognized hook, quickback/hooks/before-email-otp-activate.ts, receives a proven dormant user { userId, email, user } plus transaction-bound db / full schema and authDb / authSchema handles. Quickback owns only identity proof and atomic orchestration; application admission, status, event, pass, and eligibility rules remain authored in the project API hook.

The hook executes after OTP validation but in Better Auth's databaseHooks.user.update.before, before the user update lock. This preserves application-first lock ordering and avoids the user-to-application inversion a session-create hook would introduce. Unknown-email signup must be disabled; already-verified users do not rerun dormant activation.

The generated handler rebuilds Better Auth over the transaction handle, so OTP consumption, project hook writes, the emailVerified update, and session insert commit together. Admission denial, a non-ok response after activation starts, and any 5xx response throw a private rollback sentinel. Expected policy denial returns a generic 403. Operational rollback emits the metadata-only auth.atomic_email_otp_operational_rollback event and returns a generic 503, without exposing raw errors. Ordinary invalid-OTP 4xx responses still commit Better Auth's attempt accounting, and the OTP-send route remains outside the transaction. Unsupported contracts, runtimes, database modes, missing/duplicated config, enabled signup, and a hook/config mismatch now fail at compile time.

Neon migrations can declare cross-schema foreign keys

Neon projects can now declare fully qualified, single-column or composite foreign keys under compiler.migrations.foreignKeys. Each entry names the constraint, source and target schema/table/column tuples, and explicit onDelete / onUpdate actions. The compiler rejects unsafe identifiers, mismatched tuple arity, duplicate or ambiguous declarations, unknown keys, and non-Neon providers before generating SQL.

Declared constraints are sorted deterministically and folded into Quickback's content-addressed Postgres journal after the Drizzle schema migration. A new constraint is added NOT VALID and then validated, so existing orphan rows fail the migration; replay verifies an exact catalog match and rejects a same-named constraint with different semantics. This surface enforces relational integrity only—business rules remain in generated or authored API actions, not database triggers.

Compile completion steps honor the Neon connection mode

The CLI's successful-compile footer now distinguishes Neon HTTP from Hyperdrive. HTTP projects retain the direct DATABASE_URL Worker setup. Hyperdrive projects show DATABASE_MIGRATION_URL only as a local or CI migration input, point local development at the configured Hyperdrive localConnectionString, and never recommend deploying DATABASE_URL as a Worker secret.

Named Hyperdrive environments also show their configured Neon branches, required-secret inventories, and explicit Wrangler --env targets. The footer no longer suggests the unnamed .env.neon / .dev.vars workflow or a bare deploy for an isolated dev / prod setup.

Cloudflare email delivery and named Neon targets fail visibly and deploy explicitly

Generated Cloudflare email code now uses the native Workers SendEmail, EmailAddress, attachment, and EmailSendResult contracts. The Workers builder keeps its camel-case fields and { email, name } address shape, while the REST transport retains its separate snake-case fields and { address, name } shape. Generated projects move to @cloudflare/workers-types v5 with Wrangler 4.110 so the binding and result types agree without any casts.

Every Better Auth email callback now creates an observed delivery promise. On Workers, the callback hands that still-rejecting promise to executionCtx.waitUntil(...) and returns after the task is accepted; outside a request lifecycle it returns the promise for the caller to await. Failures emit only a structured generic event, never provider error content, and remain rejected instead of being detached or swallowed. SES and SNS background sends use the same lifecycle rule.

Neon Hyperdrive's generated postgres.js client now caps each request-scoped pool at five connections. Named environments gain explicit deploy:dev and deploy:prod package scripts (mapped to their configured Wrangler environment names), while the unsafe bare deploy script remains absent and migrations remain a separate target-owned step.

Contract v2 auth and Neon authority boundaries are fail-closed

Contract-v2 projects no longer emit Quickback's generic JWT mint endpoint, JWT helper, middleware verification/mint fast-path, or related imports unless the project explicitly declares auth.jwt. Contract v1 retains its existing default for compatibility. Adding auth.jwt: {} to a v2 config opts back into the custom Quickback JWT with its normal default settings.

Configured delegated authenticators now produce closed TypeScript unions in both generated AppContext and Neon RLS context. Request contexts contain only resolved principal variants; the compiler-internal credential-lookup variants exist only in the database context. Delegated standalone actions do not require Better Auth activeOrgId / activeTeamId before their declared principal access tree runs, and their context cannot acquire account, organization, team, membership, or scope authority.

On Cloudflare + Neon, the API-key organization membership lookup now runs with request-scoped user/org RLS claims before it stamps verified organization and role authority into AppContext. Generated security migrations also converge direct privilege drift: the audit schema/table revoke all direct grants from PUBLIC, the runtime, and admin before restoring their exact write-only/admin sets, while the Drizzle journal schema/table/sequences and database CREATE capability explicitly revoke PUBLIC and runtime access before granting the migration role.

Standalone action routes preserve their exact authority context

Generated standalone routes now pass each action the context and scoped database types inferred from that action's own execute signature. Account actions therefore receive a required account identity, while event_pass, event_delegate, and other delegated-principal actions retain their exact principal literal at the route boundary. Relationship loads / exposeAs hydration is intersected with that authority context, including required shared targets and optional distinct multi-lane targets, instead of replacing it with the broad application context.

The same boundary keeps ordinary request databases and temporary service-role-backed scoped databases assignable to the generated Hyperdrive action helper without widening the handler's schema-aware db type.

Generated principal-auth identity guards also emit explicit strict TypeScript parameter and return types. Contract-v2 Cloudflare + Neon output no longer fails tsc on implicit-any guard parameters.

Parameterized actions keep complete v2 schema harvests

The action-input harvest plan now converts Hono-style :param segments to the OpenAPI {param} form for standalone actions and parameterized resource paths. Previously the CLI could harvest every schema successfully, but the compiler could apply only parameter-free actions because it looked up parameterized operations under the wrong OpenAPI key. Contract v2 now counts and patches the same canonical path emitted by OpenAPI and MCP.

Generated standalone-action routes also remove template-only trailing spaces when optional audit blocks are absent, keeping regenerated source diffs clean.

Action-schema harvest uses the generated runtime dependency tree

The CLI now resolves action-harvest dependencies from the configured build.outputDir/node_modules before unrelated ancestor installs. This fixes isolated projects that keep authored definitions under quickback/ and the generated runtime under a sibling directory such as src/: their actions can use the runtime's Zod 4 and Drizzle installation without adding workspace-root symlinks. An older user-level or monorepo-level Zod can no longer shadow the runtime's declared version during schema evaluation.

Schema-only evaluation also emits inert named table proxies for authored Drizzle exports. Shared input/DTO modules may therefore construct top-level selection objects such as { id: records.id } without executing a database operation or losing the action's Zod schema.

Harvest failures now retain the exact action key, failing phase (bundle or evaluation), and bounded underlying error. Contract v2 includes those per-action diagnostics in its fail-closed compile error; v1 retains its best-effort static-schema fallback.

Contract v2 delegated principals and RLS-only action tables

Cloudflare + Neon Hyperdrive projects on contract.version: "v2" can now declare framework-generic delegated authenticators under auth.principals. Each named principal owns an exact, non-Bearer Authorization scheme, a project hook that proves the credential and returns only { digest }, and one compiler-owned indexed lookup that resolves the actor, optional session/family, string claims, and active status. The hook receives no database handle. Proof hooks authored under quickback/lib are staged into generated src/lib, even when no action imports them directly.

Delegated authority is structurally disjoint from Better Auth accounts: ctx.principal is populated while user, organization, team, and membership fields remain absent. Access arms may use principals: [...]; fields inside an arm are ANDed and sibling arms are ORed. Generated action helpers preserve that boundary in TypeScript, including the anonymous possibility of a PUBLIC sibling arm. Mixed credential transport rejects Authorization plus an API key or an actual Better Auth session cookie, while unrelated analytics cookies do not create a false conflict. Bearer remains reserved for account authentication.

Resources can also declare operation-specific databaseAccess policies for authored actions and support tables. This is an RLS-only surface: it emits no generic route or OpenAPI operation and is accepted only on Cloudflare + Neon Hyperdrive. Every authorization path must contain a nonempty SQL-lowerable record equality; unbounded principal/role leaves, any unbounded OR arm, empty records, ambiguous mixed boolean nodes, app-only predicates, and unsupported providers fail compilation. This keeps business logic in authored API actions while Postgres independently enforces the same event/person or account/org row boundary.

Principal claims use the same transaction-local Hyperdrive preamble as account claims, with query caching disabled. Restrictive per-operation principal fences prevent a public, exception, or legacy permissive policy from admitting the wrong principal type. Credential lookup indexes and RLS journal entries are content-addressed and converge when a table, digest column, or definition changes; generated Postgres identifiers include collision-resistant hashes and stay within the 63-byte limit. Each security migration also drops the complete compiler-owned databaseAccess policy family before recreating the current operations, so removing or moving an operation revokes the old permissive policy instead of leaving it active. Record fields resolve through authoritative Drizzle metadata to exact quoted physical identifiers, unknown fields fail the compile, and DB-only pseudo-roles follow the same ADMIN/SYSADMIN/INTERNAL contract as the application surface.

Action execute helpers preserve their typed boundary

Generated per-feature defineAction helpers no longer erase services, the Hono request context, record predicates, or the action's return value to any. Zod input and record inference remain intact, whereRecord and whereTransition accept only the helper's bound table, and the existing D1 / Neon Hyperdrive scoped db and interactive tx types now flow through the same execute boundary. The returned value is inferred exactly; this does not add a response schema, invalid-return rule, or new output contract.

HTTP/WebSocket Neon retains its intentionally dynamic db, and AppContext retains its open index signature for runtime-hydrated namespace fields.

Neon Hyperdrive is now the only Worker database lane, including Better Auth

For connectionMode: "hyperdrive", generated Better Auth code now uses the same HYPERDRIVE-backed, service-role-scoped database handle as the rest of the generated API. DATABASE_URL is no longer emitted or required as a Worker secret in Hyperdrive mode; migration credentials remain inputs to the compiler or CI environment. HTTP-mode Neon retains its existing DATABASE_URL runtime contract.

Public contract v2 now compiles as one coherent API/auth surface

Projects may set contract: { version: "v2" } to select /api/v2/* feature routes and /auth/v2/* Better Auth routes. The compiler now threads that selection through generated route mounts, Better Auth configuration and middleware, OpenAPI paths and server metadata, MCP internal tool calls, OAuth discovery, llms.txt, schema/embeddings/system routes, live views, namespace actions, and dedicated-domain /v2/* shortcuts. The option is no longer fail-closed. Omitting contract or selecting v1 preserves the existing v1 output.

Bundled CMS and Account clients now receive the selected API and auth base paths through both their Vite build environment and the Worker-injected runtime config. CMS CRUD, schema, sysadmin, and live-view requests and Account auth, data, and live-view requests therefore stay on /api/v2 and /auth/v2 for a v2 project instead of falling back to client-side v1 literals. V1 SPA config retains its historical byte shape.

V2 compiles also require a complete client-side action input-schema harvest. The current CLI reports the exact action files that failed to produce a JSON Schema, and the compiler independently rejects missing, invalid, or partial maps at the shared parse boundary used by both complete and validation-only compiles. POST /compile?complete=false can no longer bypass the v2 gate. For record actions with bulkVariant: true, the harvested schema now patches both the single-record request body and the bulk operation's nested input; the action is counted complete only when both OpenAPI targets exist. Harvest planning also normalizes filename-based action bindings to the table's declared name and resource path, so kebab-case filenames exporting snake/camel-case tables are not silently omitted. V1 remains best-effort for backward compatibility.

Complete, isolated Wrangler targets for named Neon environments

providers.database.environments now defines deployable Neon Hyperdrive targets instead of emitting empty [env.*] shells. Because Wrangler does not inherit vars or bindings into named environments, each target must explicitly own its generated vars, required-secret contract, Hyperdrive and KV IDs, rate-limit namespace IDs, and Cloudflare Email binding. The compiler rejects missing overrides, shared stateful IDs, optional secret declarations, invalid target semantics, and unsupported binding families rather than generating a Worker that starts without part of its runtime.

The required-secret inventory is now centralized and includes generated credentials as well as custom declarations: BETTER_AUTH_SECRET, a configured auth.jwt.secretEnv, ENCRYPTION_KEK when envelope encryption is present, and every bindings.secrets entry marked required. Generated Better Auth SES/SNS credentials, AWS-backed anonymous-upgrade credentials, enabled social-provider client id/secret pairs, and R2 presign credential env names are included as well. Cloudflare social auth now reads those credentials from the Worker env binding instead of relying on process.env compatibility behavior. Named targets also reject every generated top-level custom-domain source — including runtime routes, the primary/CMS/Account/Admin/Auth/API domains, and app domains or aliases — until routes can be declared with explicit per-environment ownership; top-level routes can no longer produce an ambiguous deployment target.

Named targets are currently limited to Neon Hyperdrive with Better Auth. External auth remains fail-closed until its service binding can be declared independently for every named environment.

For named-environment projects, the base Worker has no deployable stateful bindings, local development selects a named target, and that selected target is always the required logical dev environment, which must own an explicit development-only Hyperdrive localConnectionString. Quickback emits it only in the selected named binding, rejects a top-level override when named environments exist, and rejects local URLs on staging or production targets; deployed targets continue to resolve the remote Hyperdrive configuration by ID. The generated package omits the unsafe bare deploy script. Existing projects that omit environments keep byte-identical Wrangler output and the existing dev/deploy scripts. See Neon → Named Cloudflare deployment environments.

Local compiler trust boundary and deterministic Drizzle commands

The supported Docker launcher now opts into unauthenticated local compilation explicitly and publishes the compiler only on 127.0.0.1; missing or unknown compiler modes retain hosted authentication. The CLI recognizes only exact loopback hostnames as local. Migration post-commands now use the configured package manager's local binary execution form, with npm forced offline and its implicit install prompt disabled, so a missing drizzle-kit fails clearly instead of fetching an unpinned package.

Action rate limits now emit — documented since the rate-limit pillar, wired now

The rate-limit docs have described per-action rate limiting since the pillar shipped: record-based actions inherit the resource's update bucket, standalone actions inherit the project-default update bucket, and actions may declare a rateLimit override or opt out with rateLimit: false. The generated routes never contained the checks. They now do, exactly per the documented contract:

  • Record-based actions (and their bulk variants, which share the same counter — one token per batch request) are keyed <resource>:action:<name>:<user>; standalone actions are keyed standalone:<declaredPath>:<user>. The :action:/standalone: infixes mean action counters never collide with CRUD counters.
  • rateLimit: { limit, period } on an action emits its own binding (deduped by tuple, same RL_<limit>_<period> scheme); rateLimit: false opts the action out entirely.
  • Record-based action routes are now exempted from the per-resource CRUD catch-all middleware. Previously a POST /:id/approve was incidentally counted against the resource's create bucket — wrong bucket, wrong key.
  • Actions-only (tableless) projects now provision the project-default binding; previously they shipped with none.

Behavior change: standalone actions were previously unlimited; they now inherit the project-default update bucket (200 requests / 60s per user/IP with shipped defaults). PUBLIC webhook-style endpoints that absorb provider bursts should declare their own budget (rateLimit: { limit: 1000, period: 10 }) or opt out (rateLimit: false). The compiler emits a warning when a PUBLIC standalone action silently inherits the default, so affected actions are flagged at compile time.

Better Auth CLI pinned and shipped as a generated devDependency

The generated auth:schema script runs npx better-auth generate, but the better-auth runtime package ships no executable — the better-auth bin lives in the npm package auth (the official Better Auth CLI; the team took over that name at 1.5.0, superseding the frozen @better-auth/cli). Generated projects now declare auth as a devDependency pinned to the runtime's minor line, so npm run auth:schema resolves the CLI locally instead of requiring a global install. The compiler image's global CLI is pinned to the same version, eliminating silent skew between the CLI on latest and the pinned runtime.

Compile warning when an action's input schema degrades to untyped

When action input schemas aren't harvested client-side (older CLI, or a direct /compile API call), the static Zod parser supplies OpenAPI/MCP input schemas — and silently degrades unrecognized constructs (discriminated unions, refinements, z.record, transforms) to the accept-anything {}. That degradation is no longer silent: the compile result now carries a warning per affected action naming the route, source file, and the exact untyped field paths. Actions covered by the CLI harvest are never warned on, and actions that declare no input are skipped.

Auth docs: JWT security posture stated up front

The Auth & JWT page now leads with the posture summary instead of burying it: the default 180-second auto-refreshed TTL plus revocationCheck: 'kv' (seconds-level revocation, auto-stamped on ban / member removal / role change / scope-conferring row changes) is the recommended configuration and covers the vast majority of deployments — with the residual trade-offs (per-principal granularity, fail-open on KV outage, ~60s cross-PoP propagation) stated explicitly for strict threat models.

Neon via-user reads: generated list handlers now typecheck against the concretely-typed service handle

On Neon, a read firewall carrying a via-user multi-hop relationship predicate routes the generated read handlers to createServiceDb(c.env) — a genuine NeonHttpDatabase<typeof schema> — instead of the loosely-typed request db. The list handler's query construction declared drizzle select builders with let and reassigned them (query = query.where(…), .orderBy(…), .limit(…).offset(…), and the count/aggregation/group-by variants), which is a type error against drizzle's immutable builder typing: .where() returns Omit<PgSelectBase<…>, "where">, not the declared type. Every affected declaration now appends .$dynamic(), drizzle's sanctioned mode for incremental query building — types only; the emitted SQL is unchanged. Reads routed through c.get('db') were never affected at runtime (the handler code is identical); they simply never surfaced the latent typing defect because the request-db handle is not concretely typed. The Neon output tsc gate now also stages the emitted service-read route files (via-user fixture) with a control that re-strips .$dynamic() and asserts the failure, so query-builder typing regressions in generated route files fail CI.

ws-ticket minting now accepts all three authorization vocabularies

POST /broadcast/v1/ws-ticket could previously be gated only by an authz relationship role (wsTicket.role) or a namespace. The mint gate now takes an explicit discriminated access form — the arm names the vocabulary, so a bare name can never silently shadow across vocabularies:

  • access: { roles: ["member+"] }org-membership roles, with auth.roleHierarchy + expansion (compile error without a hierarchy). Firewall-consistent: the route verifies the caller's org role and that the requested scopeTable row belongs to the caller's active organization — an org-A member cannot mint a ticket to org B's room. Requires an organization column on the scope table (compile error otherwise). UPPERCASE pseudo-roles are rejected: a PUBLIC/AUTHENTICATED ticket factory would hand signed room credentials to callers with no org standing.
  • access: { authzRole: "attendee" } — the existing relationship-role gate, unchanged. The legacy role: "attendee" string remains the shorthand and compiles byte-identically (regression-pinned against a golden fixture).
  • access: { fga: { relation: "viewer", object: "event:{id}" } } — an FGA relation on the object derived from the tenant-bound scope row. The object template is the read path's access: { fga } language, and the check runs through the same org-scoped evaluator (ctx.fga.check) — not a second one. The object type and relation are validated against the authz.fga model at compile time; a non-tenant-scoped scopeTable is rejected like the namespace path.

Everything fails closed at compile time with actionable errors: exactly one authorization source (access never coexists with role or namespace), exactly one access arm, ambiguous names living in two vocabularies, missing hierarchy, missing FGA model, and missing organization columns all fail the build. See Realtime.

Neon hyperdrive: generated env.d.ts now types the HYPERDRIVE binding

connectionMode: "hyperdrive" projects emit a [[hyperdrive]] binding = "HYPERDRIVE" wrangler block and a db runtime that requires env.HYPERDRIVE.connectionString — but the generated src/env.d.ts omitted the binding, so every generated createDb(c.env) / createServiceDb(c.env) call site (middleware, routes, webhooks, seal routes) failed tsc with Property 'HYPERDRIVE' is missing in type 'CloudflareBindings'. The Env interface now declares HYPERDRIVE: Hyperdrive (the @cloudflare/workers-types global) whenever the resolved Neon connection mode is hyperdrive. The wrangler-binding completeness matrix gained neon http/hyperdrive cases pinning env.d.tswrangler.toml agreement, and a new test typechecks the real emitted env/db files with the exact generated call shape.

Neon hyperdrive: typed action db accepts explicit values for defaulted columns

On the hyperdrive typed action surface, db.insert(table).values({ id: ..., ... }) failed tsc with 'id' does not exist in type 'QbPgScopedInsertValue<…>' for any column with a schema default — explicit ids on $defaultFn primary keys, explicit status on defaulted enums, explicit audit timestamps. The scoped insert model was derived from drizzle's PgInsertValue<TTable>, which drops every optional (defaulted) key when instantiated through a generic type parameter under TypeScript 5.9. It is now derived from the table's own $inferInsert (which survives generic instantiation), preserving optionality and drizzle's per-column SQL | Placeholder escape hatch. Required columns, unknown-column rejection, and the auto-scope-column relaxation (organizationId/ownerId/teamId optional) are unchanged. Note: pg timestamp columns are Date-typed — passing new Date().toISOString() (the D1 string convention) into a typed insert or update still fails tsc, matching stock drizzle semantics; pass a Date.

Production 500 bodies are now generic — error internals stay in the logs

Unhandled-error responses (INTERNAL_ERROR from the global onError, ACTION_EXECUTION_FAILED from action handlers) previously forwarded the underlying error message, cause-chain message, and the top three stack frames in the response body by default. Those can carry SQL fragments, file paths, or secrets, so the default body is now generic: { error, code, layer, hint, requestId } plus details.name (error class only) on action failures. The full error — message, cause, stack — is always console.error'd keyed by the requestId echoed in the body. Setting EXPOSE_ERROR_STACK=1 (dev/staging) restores the verbose body: message, details.cause, sanitized details.frames, and the full details.stack. See Errors.

wrangler.toml now declares every binding the generated code references

Two configurations generated code referencing a binding that wrangler.toml never declared — the env key was undefined at runtime and the first use 500s:

  • Resource-level realtime (realtime: { enabled: true } on a table, without the top-level realtime: true database flag) emitted the realtime routes, the BROADCASTER env type, and the worker's Broadcaster DO export, but no [[durable_objects.bindings]] / [[migrations]] block — every broadcast failed at runtime. The wrangler emitter now derives realtime from the same resolved surface as the rest of the compiler (top-level flag OR any resource opt-in), so the DO binding and migration are always emitted together with the code that uses them.
  • splitDatabases: false + webhooks dropped the WEBHOOKS_DB [[d1_databases]] block that the generated webhooks lib reads. Single-db projects with webhooks now get the block, matching split mode.

A compile-level test matrix (split on/off × files × webhooks × realtime) now pins binding uniqueness and completeness: every D1Database / DurableObjectNamespace binding typed in src/env.d.ts appears in wrangler.toml exactly once.

Security: three fail-closed fixes in the generated runtime

  • Scoped db blocks Drizzle's relational query API. db.query.<table>.findFirst() / findMany() build their SQL outside the scoped wrapper's reach, so they read across organizations. The scoped db handed to actions now throws on any db.query.<table> access (and the Neon hyperdrive typed db/tx fails tsc on it) with directions to db.select() (auto-scoped) or an unsafe: true action's unsafeDb.query with an explicit scope predicate. Handlers that relied on the unscoped pass-through were reading other tenants' rows and must be updated. See Relational queries are blocked.
  • POST /api/v1/token requires session authentication. The auth middleware now stamps ctx.authMethod ('session' | 'jwt' | 'api-key' | 'oauth'), and the token endpoint rejects anything but 'session' with 401 AUTH_SESSION_REQUIRED. Pre-fix, a JWT could mint its own replacement — a stolen or revoked token (banned user, removed member, demoted role) could roll a 180s-TTL token forever without re-validating the session.
  • Broadcaster DO no longer trusts the X-Internal-Call header alone. Inline-mode checkAuth now requires X-Internal-Secret to match BETTER_AUTH_SECRET (already provisioned; the secret never leaves the worker/DO boundary on a stub call), separate mode compares ACCESS_TOKEN constant-time, and every generated internal caller (lib/realtime.ts senders, the live-view seq reader, the Better Auth role-change hook) sends the secret. A spoofed header can no longer reach broadcast injection or webhook repointing through any forwarded route.

Neon reads through user-anchored relationships

Resources readable via a multi-hop chain anchored on the caller's user id (e.g. a conference attendee reaching their own registrations through person_account_links → people → event_people → registrations) now work on Neon. Declare the chain once with the hops form on an authz.relationships entry and reference it from a resource firewall via { field, via }. Because the correlated multi-hop join reaches across intermediate tables that each FORCE ROW LEVEL SECURITY, caller-claims RLS would deny such a read to zero rows — so the compiler routes only that resource's read handlers (collection GET /, GET /:id, and views) through the service-role handle (createServiceDb), with the compiled app-layer reachability WHERE as the sole row filter. Org/owner/team reads are untouched (caller claims + RLS backstop); writes are unchanged. The swap is fail-closed — a service-handle read with no firewall WHERE is a compile error. The anchor table is required to be caller-read-own on the subject column and service-role-write-only (a forgeable link would mint reachability to a victim's data); this link-integrity check is target-independent and fails the compile on both Neon and D1. The same config compiles identically on D1, where the app-layer WHERE was already the whole firewall. See Reads through a user-anchored relationship.

Neon interactive transactions via Hyperdrive

A Neon project can now opt into connectionMode: 'hyperdrive' to run its feature database over a Cloudflare Hyperdrive binding (postgres.js / drizzle-orm/postgres-js), unlocking real interactive transactions in generated actions: read current state, lock it (for update), decide in TypeScript, then write dependent rows — committed atomically, rolled back on throw. tx is fully schema-typed (invalid columns fail tsc), the verified RLS claims run as the first transaction-local statement inside the transaction (no leak across the connection pool), and Hyperdrive query caching is force-disabled (ETags cache at the correct post-auth layer). http mode stays the lightweight batch-only default and is byte-identical; requesting db.transaction() outside hyperdrive mode is a fail-closed compile error. WebSocket mode is deprecated — Hyperdrive supersedes it as the full-capability path. See Interactive transactions.

Neon migration & environment tooling

environments config maps dev/staging/prod to Neon branches + Cloudflare envs; quickback canary <env> branches the target DB, applies pending migrations to the branch, diffs, and (with --apply) promotes — verifying schema changes against real state before they touch a live environment. Record-condition firewall predicates now lower to Postgres RLS policy arms (enforced twice — app layer and database). Opt-in API contract.version scaffolding landed (v1 default; v2 fail-closed pending completion).

Neon PostgreSQL reaches deploy parity with D1 (v0.50)

A Neon-backed service now compiles, typechecks, migrates, and deploys with the same capability surface as a D1 service — over HTTP (@neondatabase/serverless), the Cloudflare default. There is no Hyperdrive binding and no Neon Authorize / JWKS configuration: the Worker verifies each caller, then writes the trusted user/org/team context into transaction-local Postgres settings (set_config('request.jwt.claim.sub' | 'org_id' | 'team_id', …, true)) as the first statement of every query batch. RLS reads them through a compiler-defined auth.user_id() shim and the get_active_org_id() helper — not a persisted user_sessions mirror (which never shipped).

  • Background contexts run under a service-role handle. Queue consumers, cron schedules, and cross-tenant unsafe actions acquire createServiceDb(env), which sets quickback.service_role = 'true' (all request claims cleared); every feature table carries a <table>_service_role RLS policy admitting exactly that context. Unsafe-action audit events are unchanged.
  • Webhooks port to a webhooks Postgres schema in the same database (no WEBHOOKS_DB binding). webhooksBinding is a pure enable flag on Neon; the D1-only webhooksDatabaseId / webhooksDatabaseName keys are rejected. The store is service-role-only.
  • .encrypted() / .sealed() columns are supported on Neon over HTTP: the key/vault tables fold into the journaled migration set with org-scoped and service-role-only RLS.
  • Batch transactions advertise the truth. Neon HTTP is fail-fast and non-transactional (meta.transactional: false, like D1) because drizzle-orm/neon-http has no interactive transactions; Neon WebSocket (Node/Bun) gets real rollback (meta.transactional: true).
  • Deploy glue matches D1: a generated deploy script (npm run db:migrate && wrangler deploy), a boot check asserting DATABASE_URL, .dev.vars.example (wrangler dev reads .dev.vars, not .env) gitignored, and drizzle.config.ts preferring DATABASE_MIGRATION_URL ?? DATABASE_URL for the privileged migration role.
  • Fail-closed capability gates: managed file storage (use presign-only R2), the Better Auth subscriptions plugin, raw sqliteTable interop sources on a Postgres target, and — under WebSocket mode — webhooks / unsafe actions / encrypted columns are all compile-time errors rather than silently-broken output. Mixed database providers remain unsupported.
  • Docs corrected: the Neon page no longer describes Neon Authorize, a user_sessions table, or Hyperdrive — none of which the compiler emits.
  • Migration-journal hardening. The role bootstrap now grants quickback_admin everything the pinned Drizzle runner's journal needs — database-level CREATE (so CREATE SCHEMA IF NOT EXISTS drizzle passes even as a no-op) plus ownership of the drizzle schema and drizzle.__drizzle_migrations — so routine migrates after the owner-run bootstrap need no owner credential. Generated Postgres migrations are also post-processed so a referenced composite UNIQUE / PRIMARY KEY constraint always precedes the foreign keys that consume it (an upgrade delta applies unedited instead of failing with "no unique constraint matching given keys").

Realtime identity tags — one send reaches a person on either auth door (v0.49)

WebSocket connections now carry identity tags, proven at ticket mint and signed into the ws-ticket (never client-supplied): user:<userId> for every Better Auth caller, plus scope:<kind>:<subjectId> whenever a scope subject is proven — on either door. A sessionless scope principal's claim and a Better Auth caller's matched relationship lane stamp the identical subject-keyed tag (both derive from the same relationship row), so senders no longer double-send per recipient to cover subject: 'both' roles.

  • targetTags on every send (realtime.broadcast, realtime.insert, …): delivery is the deduplicated union of the runtime's indexed getWebSockets(tag) lookup — no per-frame attachment scan. Replaces the userId identity arm when present; the scope/org bind and targetRoles audience still apply.
  • Fail-closed: targetTags: [] delivers to nobody, consistent with the empty-audience rule.
  • Tags are immutable for a socket's lifetime (hibernation-tag semantics); limits (10/socket, 256 chars) are validated at mint — rejected, never truncated.

Realtime authorization lease — hibernated sockets no longer outlive revocation (v0.49)

A ws-ticket was verified once at upgrade, and DO hibernation kept the socket authorized forever — bans, member removal, and subject revocation never touched a live connection. Connections now carry a delivery-time authorization lease (default 15 minutes, from ticket iat):

  • The fan-out skips expired connections (they receive nothing) and closes them with code 4401; inbound messages — even pings — are enforced the same way.
  • In-place renewal: { type: "reauth", ticket: "<fresh ws-ticket>" } extends the lease and refreshes roles without socket churn. Renewal is strict — same userId, same scope/org, exact same tag set (tags are immutable); identity drift closes with 4401 → reconnect fresh.
  • Renewal tickets come from POST /broadcast/v1/ws-ticket, which re-runs the full authorization gate (session, KV revocation, relationship/scope-claim probes) — so revocation propagates within one lease period, with no KV reads in the Durable Object.
  • Configure via realtime: { lease: '15m' | seconds | false } (min 60s; false disables — discouraged). Clients that never reauth degrade gracefully to one forced reconnect per lease.

Realtime broadcasts are fail-closed — explicit per-resource audience (BREAKING)

Realtime broadcasting is now opt-in per resource with a mandatory audience, mirroring how CRUD is explicit. This closes a fanout footgun where a resource that never mentioned realtime could still broadcast its rows to an entire room.

  • realtime.access: { roles: [...] } is the audience — same { roles } shape as read.access / crud.*.access. A subscriber receives a live row iff that role bucket would admit them at REST. Roles may be concrete membership/scope roles or UPPERCASE pseudo-roles (PUBLIC, AUTHENTICATED, USER, SYSADMIN).
  • Fail-closed, at compile time — a broadcasting resource with no/empty audience is a compile error naming the resource. There is no implicit fanout. To broadcast to everyone in the room, say so deliberately with access: { roles: ['PUBLIC'] }.
  • Fail-closed, at runtime — the Broadcaster now treats an empty audience as nobody (previously: everybody) and evaluates the uppercase pseudo-roles exactly as the REST access evaluator does (userRole is threaded through the ws-ticket for USER/SYSADMIN).
  • No project-wide auto-broadcastproviders.database.config.realtime now only wires the transport (Broadcaster DO + ws-ticket route); it no longer enables broadcasts on any resource.
  • Migration — a project that relied on the project-wide auto-broadcast must add an explicit realtime: { enabled: true, access: { roles: [...] } } block to each resource that should broadcast. requiredRoles is the deprecated spelling of access.roles (still compiles, normalized internally). q-events guests demonstrates the staff-lane pattern (access: { roles: ['admin','member'] }, event-scoped, masked).

Deterministic codegen output

The compiler now sorts every filesystem-discovered collection (features, and each services.* group) by a stable key before generating, so recompiles produce byte-identical output regardless of the CLI's directory-scan order. Eliminates the churny, behavior-neutral diffs (queue-consumer case order, auth blocks, schema imports) that appeared across machines/CLI versions.

acceptScope: one namespaced handler for the user AND the sessionless principal

A project-level namespace can now accept an already-minted scope claim as proof of entry — the inverse of mintScope. Set authz.namespaces.<ns>.acceptScope: '<kind>' and the hoisted resolver gains a fast-path: a scope-only principal (ctx.scopePrincipal, no ctx.userId) carrying a verified ctx.scope.<kind> claim proves entry by that claim, without a DB relationship probe. One /event/:eventId/* handler then serves both the Better-Auth user and the sessionless invite-principal — retiring the parallel PUBLIC /attendee/* twin.

  • Named-string only — the kind is always explicit; a bare true is a type error. Default absent → the resolver never admits a scope principal (fail-closed).
  • Two locks, compile-enforcedacceptScope opts the namespace in, and at least one relationship lane must confer a <kind> role declared subject: 'supplied' \| 'both'. A subject: 'user' role emits no accept arm; a kind whose every conferring lane is user-only is a compile error, not a dead fast-path. The kind must be declared and its requestField must equal the prefix's first :param.
  • Forging-safe — the claim is verified (never caller-asserted), the kind is read by name (not an any-key loop), the instance is pinned twice (entry.id === :param in the gate + eq(loads.pk, :param) in hydration), and each accept arm is one exact (role, lane) pair. New proof source, zero new enforcement surface — firewall, masking, access, and scoped-db read the same ctx.scope they read for a userId caller.
  • Composes with mintScope — a userId caller still gets a rolling re-mint; a scope principal's verified claim is preserved (not re-stamped) so its subjectId / sub-keys survive.
  • Realtime shares the same acceptance — the ws-ticket route now proves a scope principal through the same emitter as the REST gate (emitScopeAcceptance), so the two surfaces can't drift. Bringing the ws-ticket path onto it tightened it to the named-kind read + supplied-reachable role filter, closing an any-kind cross-kind match. q-events ships a vendor scope role (subject: 'both') + logistics-only view demonstrating the sessionless supplier end-to-end.
  • Writes work too — the scoped-db audit wrapper no longer throws when the caller is a verified scope principal (ctx.scopePrincipal, no ctx.userId). Instead of failing on the missing user, it stamps the synthetic, audit-only principal id (scope:<kind>:<subjectId>, the token's sub) into created_by / modified_by. So the SAME /event/:eventId/* handler body serves both a Better-Auth user and the sessionless principal on reads AND writes — no parallel raw-db write path. Still fail-closed: a genuine no-user write (no scope principal) throws exactly as before. q-events adds a vendorCheckin write under the acceptScope namespace exercising it end-to-end.

See Scopes → acceptScope.

Sessionless scope mint: ctx.mintScope + subject: 'supplied' \| 'both'

A scope can now be minted for a principal who has no Better Auth account — an event attendee arriving by email + invite code, a kiosk/handout code, a partner token. The action proves the relationship its own way and asks Quickback to mint a disciplined scope token from the row it proved.

  • authz.scopes.<kind>.roles.<role>.subject: 'user' \| 'supplied' \| 'both' (default 'user'). 'both' makes the same scope:event:attendee reachable by a logged-in user (via /scope/v1/enter) and a sessionless invite-holder (via ctx.mintScope) — one firewall arm, one masking rule, both principals.
  • ctx.mintScope({ via, subject }) — hydrated on ctx in any action (including PUBLIC) when a supplied/both role is declared. The action supplies only the conferring row's primary key; Quickback reads the role, resource id, sub-keys, and subject id off that row — never caller-asserted, so the role can't be forged. Returns a scope-only JWT (no user identity): ctx.userId stays undefined, so user/org/owner gates and the AUTHENTICATED/USER pseudo-roles all fail closed; only scope:<kind>:<role> gates admit it.
  • ctx.scope.<kind>.subjectId — a reserved claim carrying the proven subject row's PK, populated on both proof paths. Scope an attendee's self-data (travel, dietary, todos) with a single firewall arm ({ field: 'guestId', equals: 'ctx.scope.event.subjectId' }) that serves both a user-attendee and a sessionless one.
  • Realtime — the ws-ticket authorizes a scope-only principal directly off its proven ctx.scope claim, so accountless attendees join the same rooms with the same scope role. The where: filter runs on the mint probe, so a cancelled row confers nothing (revocation within one ≤180s mint cycle).

See Scopes → Sessionless scope mint.

MCP OAuth: split authorization-server / resource-server (multi-app SSO)

You can now run one canonical authorization server with many resource servers (each hosting its own /mcp) over a shared auth DB:

  • A resource server that sets agents.mcp.requireAuth now emits the full OAuth access-token verification path on its own — previously the JWKS-verify block only emitted when the separate auth.config.oauth.enabled flag was set, so a requireAuth-only resource server fell back to HMAC + session and 401'd every valid externally-issued bearer.
  • Point a resource server at an external AS with auth.config.oauth.issuerUrl; the middleware verifies incoming bearers against the shared JWKS (iss/aud/exp + live-session binding).
  • auth.config.oauth.audiences (an array) registers federated resource-server audiences on the AS (RFC 8707). It now unions with the AS's own origin instead of replacing it, so listing federated servers never drops the AS's own /mcp audience. Also exposed on the plugin-form oauthProvider.validAudiences.

See Agents → Split authorization server.

MCP tools: callable path params + typed input schemas

An action mounted under a path that carries a parameter — a namespace prefix like /scope/:scopeId/..., or any path: with :params — now produces a fully callable MCP tool:

  • The mounted route's path params are extracted into the tool's inputSchema (and _pathParams), so the model has a field to supply each one. Previously only record-based actions emitted a param (the hard-coded id); a standalone or namespace path param was absent from the schema, the URL resolved with an empty segment, and the action was uncallable over MCP.
  • Standalone routes are stored in Hono colon form (:scopeId); the OpenAPI spec key (and therefore the tool's _path) is now normalized to brace form ({scopeId}) so the runtime's brace-only path interpolation can fill it.

Action input schemas also lower to richer JSON Schema instead of a phantom empty {}: z.coerce.*, z.literal, unions of literals, and nested z.object (child keys stay nested) now emit their real types, so the model — and the OpenAPI spec — see the actual field shapes. See Agents → MCP tools.

Scaffold surfaces MCP/OAuth + secrets opt-ins

quickback create now seeds the generated quickback.config.ts with curated, commented-out hints for the authenticated MCP server (agents.mcp.requireAuth), the split-AS oauth.issuerUrl/audiences, and bindings.secrets — so these opt-ins are discoverable in the config itself, not only in the docs.

Custom secrets via bindings.secrets

Declare a custom secret your feature/action code reads off env — name and contract only, never the value:

bindings: {
  secrets: [
    { name: "WEBHOOK_SIGNING_SECRET", description: "HMAC for webhook callbacks", required: true },
  ],
}

It's emitted into the generated env.d.ts Env interface so env.<NAME> type-checks, and required secrets join the boot-time env guard — the worker fails closed with 503 MISSING_ENV if one is unset. The value is set out-of-band (wrangler secret put); the schema rejects any inline value so a real secret can never be committed. See Bindings → secrets.

Auth: resolve-email-context.ts hook for per-recipient OTP context

Drop quickback/hooks/resolve-email-context.ts and the emailOTP send calls it per recipient, spreading the returned object into your email templates — branded palettes (organizer vs. attendee), plan tier, locale, etc. — without patching generated output. Fail-open, with the Drizzle handle passed in. See Auth Hooks → Email context resolver.

Codegen fixes

  • Schedules: handler import paths are now rebased on relocation, so a cron handler importing ../../features/... resolves correctly from the generated src/lib/schedules.ts instead of failing the worker build.
  • OAuth/MCP middleware: the auth-schema tables are imported under aliased names so a function-scoped const session can no longer shadow the session table import — closes a temporal-dead-zone crash that silently 401'd every OAuth/MCP bearer.
  • MCP route: the large tool-data literal is split into a mcp-tools.ts data module (suppressed) so it can't overrun tsc's type-instantiation budget; the mcp.ts route logic stays fully type-checked.

Realtime: identity-gated Durable Object rooms (roomIdEquals)

Per-user Durable Object entrypoints (gateway / presence / inbox) can now declare an identity gate on the binding — access: { roomIdEquals: 'ctx.userId' } (or 'ctx.activeOrgId'). The compiler emits a Worker-edge gate at /realtime/<kebab>/* that 401s anonymous callers, 400s a missing segment, and 403s unless the decoded room segment equals the server-resolved ctx id — a string compare with no backing table or DB query. See PartyServer rooms → Per-room authorization.

  • Closes the cross-user hole that "no access declared" leaves open: a per-user room without a gate falls through to the authenticated-only catchall, so any authenticated user could open another user's room by guessing the id.
  • The gate lives at the edge, not in the DO — a Durable Object has no session and only sees client-settable headers, so it must never re-authorize from request data (trusting an x-user-id header was the original spoof hole).
  • Only 'ctx.userId' and 'ctx.activeOrgId' are accepted; anything else is a compile-time error (fail-closed Zod + generator validation).

OAuth provider: spurious authorization-server warning silenced by default

The generated oauthProvider({...}) now emits silenceWarnings: { oauthAuthServerConfig: true } by default. The compiler always serves the /.well-known/oauth-authorization-server discovery route when the provider is enabled, so @better-auth/oauth-provider's "please ensure … exists" boot warning was always spurious. Override or extend it via plugins.oauthProvider.silenceWarnings.

defineSchedule — cron jobs from a single source

Declare a cron schedule in services/schedules/*.ts and the compiler emits the Worker's scheduled() handler + the matching [triggers] crons in wrangler.toml — the trigger-side mirror of defineQueue. No separate cron Worker, no hand-patching the generated entry (which a recompile would strip). See Schedules.

// services/schedules/sweepDigests.ts
export default defineSchedule({
  name: "sweepDigests",
  cron: "* * * * *",
  execute: async ({ db, env, services }) => { await runDigestSweep(db, env, services); },
});
  • One scheduled(event, env, ctx) dispatcher runs every schedule whose cron matches event.cron; failures are isolated per-schedule.
  • The execute context ({ db, env, services, cron, scheduledTime }) runs server-internal with ctx.internal = true and an audit actor of system:cron-<name>, plus a withInternalContext helper for roles: ['INTERNAL']-gated rows.

Rate limiting — the fifth security pillar

Per-operation request caps on every CRUD endpoint, backed by Cloudflare's native Rate Limiting binding — no extra storage, no third-party libraries. Applied to every resource by default (read 1000/60s, write 200/60s), overridable per-op, per-resource, or project-wide; false opts out at any scope.

  • One middleware per feature router maps method→op, keys the limit by <resource>:<op>:<userId> (falling back to the client IP for anonymous callers), and returns 429 + Retry-After when over the cap.
  • Unique (limit, period) tuples collapse onto shared bindings — a project that never declares rateLimit ships exactly two (RL_1000_60, RL_200_60), emitted as GA [[ratelimits]] blocks in wrangler.toml and typed RateLimit on CloudflareBindings.
  • period is compile-time constrained to 10 or 60 (Cloudflare binding limit); the check no-ops when a binding is absent, so local dev never hard-depends on it.

Envelope encryption — q.text().encrypted() (encryption pillar, Phase 1)

Mark a text column .encrypted() and it is AES-256-GCM-encrypted at rest under a per-organization key: a database breach (backup, export, leaked token, SQL injection, insider read) yields only ciphertext for that column. See Encryption.

  • Per-org DEKs live wrapped under the ENCRYPTION_KEK worker secret in a compiler-owned org_data_keys table (lazily created, versioned for rotation). Deleting an org's row crypto-shreds every value it wrote.
  • One write chokepoint: CRUD, batch, and action-layer db.insert/update all encrypt through the audit wrapper — audit snapshots, webhook payloads, and realtime deltas carry ciphertext by construction.
  • Reads decrypt behind the firewall then apply masking; encrypted columns are compile-time excluded from ?search/?filter/?sort, view query allowlists, and embedding sources.
  • ctx.crypto.encrypt/decrypt for explicit server-side processing in actions; boot guard refuses to serve without the KEK (503 MISSING_ENV); scaffolded .dev.vars now includes a generated ENCRYPTION_KEK.
  • The sealed (E2EE) and vault tiers remain compile-gated until built — roadmap in apps/compiler/research/encryption-vault-spec.md.

Postgres-style table triggers (defineTable / feature triggers:)

Tables can now declare before/after triggers on insert/update/ delete, with two lowering lanes and a per-trigger compile report. See Triggers for the full reference.

  • sql: entries compile to real SQLite CREATE TRIGGER statements, shipped in the journaled features migration (content-addressed — an unchanged trigger set appends nothing; removing a trigger drops it on the next migrate). They fire for every write path including raw SQL, run atomically with the triggering statement (the only transactional option on D1), support when: guards and RAISE(ABORT, ...) rejection, and OLD./NEW. column references are validated at compile time. The compiler owns the BEGIN/END frame — emitted uppercase, since lowercase begin breaks remote D1's statement splitter. On soft-delete tables, *Delete events map to the deleted_at transition (plus a hard-delete variant) and *Update events exclude it, so a soft delete fires delete triggers exactly once. D1 only.
  • handler: entries run as application hooks at the audit-wrapper write chokepoint (REST CRUD + actions). before* hooks are synchronous and can mutate the payload or throw to reject — the write never executes and the request gets TRIGGER_REJECTED (422); audit fields are re-stamped after hooks and cannot be forged. after* hooks run once per returned row and are awaited — a throw surfaces as TRIGGER_AFTER_FAILED (500) with details.committed: true since the write already persisted. Handlers get { table, op, values|row, ctx, db, env }; the db is audit-wrapped at cascade depth + 1, so hook writes stamp audit fields, emit live-view deltas, and fire nested triggers, bounded by a depth cap of 5. Each table's drizzle object is auto-imported into the generated registry under its authored name, and handler imports are usage-filtered and hoisted.

The compile output includes a lowering/coverage report and fail-closed warnings: handler: triggers do not fire for queue handlers, cron, or raw SQL — guards that must hold universally should use sql: + RAISE(ABORT). async: true (queue-dispatched after-hooks) and withOld: true (previous-row access) are reserved and rejected at compile time until they ship.

Also in this change:

  • New trigger error layer with TRIGGER_REJECTED / TRIGGER_AFTER_FAILED codes in the generated error catalog, OpenAPI spec, and SDK types.
  • Parser: function-valued properties in defineTable/feature configs now parse as compile-time source carriers instead of failing the whole config with "no dynamic expressions" — unblocks trigger handlers (and function-form access: / custom masks) through the CLI source path.
  • Fixed a column-injection bug where a comment merely mentioning a column name (e.g. "deleted_at" in a docblock) suppressed audit/soft-delete column injection for the table.
  • Fixed a silent no-op: a project declaring services/queues/*.ts handlers without embeddings or additionalQueues deployed a wired queue export with no [[queues.consumers]] to feed it — the handlers were dead code. The primary queue (and its typed env binding) is now emitted for handlers-only projects, with a compile notice that Cloudflare Queues requires the paid Workers plan. Queue emission remains strictly opt-in by usage: projects with no queue-backed features (embeddings, queue handlers, additionalQueues, webhooks) emit no queue configuration and deploy on the free plan.

v0.47.0 — June 11, 2026

Breaking: zero-false-positive analyzer checks are now compile errors

The authz/authn analyzer (v0.37) shipped warn-only. After a release cycle of data, the checks whose every trip is a definite misconfiguration — never a legitimate config — are promoted to compile errors. Warnings nobody is forced to read are documentation; the pillar guarantees are now enforced.

Breaking — a config that previously compiled with one of these warnings now fails the compile:

  • AUTHZ_EMPTY_PERMISSION — a permission that is structurally unsatisfiable (allOf(a, not(a)): a fact AND its exact negation at the same AND-level).
  • AUTHZ_CAPABILITY_CEILING — a resource's firewall grants a scope:<kind>:<role> role access beyond its capability profile (or the role has no profile at all). This is the M0.2 pillar guarantee — it was authored as an error all along and is now enforced.
  • AUTHN_ROUTE_COVERAGE, AUTHN_SURFACE_INVARIANT, AUTHN_SECRET_CONSISTENCY, AUTHN_CREDENTIAL_DELIVERY, AUTHN_CLAIM_PROVENANCE — the structural-authn set (route auth coverage, bearer-only /mcp/agent surfaces + INTERNAL unreachability, mint↔verify key/issuer/audience pairing, anchored credential delivery, verifier-only identity claims). These still run against empty route/credential metadata on a real compile (no-ops until the generators emit it), but the severity contract is locked in now — they fail the build the moment they can fire.

The heuristic/advisory checks are unchanged: AUTHZ_SHADOWED_ARM, AUTHZ_UNREACHABLE_ROLE, AUTHZ_UNREACHABLE_VIEW, and AUTHZ_NOT_OVER_RESOURCE stay warnings; AUTHZ_PUSHDOWN_COST stays an info report. QUICKBACK_AUTHZ_STRICT=true still promotes those warnings too.

The override escape hatch — every promoted finding can be explicitly accepted, per finding, with a required reason:

authz: {
  analyzer: {
    allow: [
      {
        check: 'AUTHZ_CAPABILITY_CEILING',
        target: 'sessions/scope:event:organizer',   // from the rendered finding
        reason: 'organizer grant profile lands with the v2 rollout (TICKET-123)',
      },
    ],
  },
}

An override downgrades that exact (check, target) finding back to a warning that cites the reason — even under strict mode (it's an explicit, reasoned acceptance). Every rendered error prints its exact override recipe. Guard rails: check must be one of the promoted ids (unknown or keep-warn ids are rejected at config validation), reason is required, and an allow entry that matches no current finding is itself a compile error — no dead overrides accumulating after the underlying issue is fixed.

Analyzer-internal defects are still fail-open: a bug in the analyzer itself downgrades to a single warning and the compile continues. Only real findings fail builds.

CLI: live views now compile through the CLI

defineView files under quickback/services/views/ were never loaded by the CLI — the compile payload simply omitted them, so live views (v0.35) only worked when driving the compiler API directly. The CLI now discovers and ships them like every other service definition, and the compile summary counts them (Loaded services: … 1 live view(s)). If you declared views and wondered why no surface ever went live: upgrade the CLI and recompile.

CLI: deterministic compile output across machines

Feature and service discovery used raw readdir order, which differs between macOS and Linux — route registration order, schema re-exports, OpenAPI paths, and the Neon RLS document's content hash could all churn when the same project was recompiled on a different machine. All discovery sites now sort by codepoint, so the same input compiles to the same bytes everywhere. Expect a one-time diff on the first recompile if your previous output was generated in a different filesystem order.

Fixes

  • Read-only resources (no crud block) generated routes importing a CRUD_ACCESS symbol their resource file never exported — a tsc/bundle break. The export is now unconditional.
  • The blog starter template gated writes on the removed ADMIN pseudo-role, which hard-fails current compiles; it now uses the org-membership admin role.
  • reports/operations.manifest.json and reports/conformance.manifest.json now also cover the realtime (/broadcast/v1), storage (/storage/v1), and FGA (/fga/v1) surfaces.
  • The dead free/pro tier plumbing is gone: no Tier row in quickback whoami, no upsell copy at login. Compiling complete projects requires login, exactly as before — there was never a paid gate behind it.
  • The CLI is published to npm via trusted publishing (GitHub OIDC) — signed provenance, no long-lived npm token in the pipeline.

v0.46.0 — June 11, 2026

Breaking: "no organization context" responses unified on HTTP 403

Generated APIs used to answer the same semantic condition — the caller has no active organization context — with two different statuses: 400 ORG_REQUIRED from the collection list guard and the create/PUT/batch org validation, but 403 ACCESS_NO_ORG from standalone actions and 403 ACCESS_TENANT_SCOPE_REQUIRED from unsafe org-scoped actions. Clients and SDKs had to special-case the split.

Breaking — recompile + redeploy required; every "no org context" denial is now 403:

  • GET /api/v1/<resource> (and views) without an active org or ?organizationId=403 ORG_REQUIRED (was 400).
  • POST /, PUT /:id, POST /batch, PUT /batch without an active org → 403 ORG_REQUIRED (was 400).
  • Standalone actions (403 ACCESS_NO_ORG) and unsafe org-scoped actions (403 ACCESS_TENANT_SCOPE_REQUIRED) were already 403 and are unchanged.

Error codes are unchangedORG_REQUIRED, ACCESS_NO_ORG, and ACCESS_TENANT_SCOPE_REQUIRED keep their identities, so clients that discriminate on code need no changes. Only clients that branch on the raw 400 status for these responses must update to 403.

Breaking: root-mounted actions lose their nested alias URLs

A standalone action whose declared path: lives outside its feature prefix (e.g. path: "/reports/orders" in the orders feature) mounts at the root: /api/v1/reports/orders. That root URL is the one the OpenAPI spec, the MCP surface, and the operations manifest have always advertised — but the generated per-feature routes file also carried an undocumented, fully-gated copy at the nested /api/v1/<feature><path> URL (e.g. /api/v1/orders/reports/orders).

Breaking — the nested alias is no longer emitted. One URL per operation: a root-mounted action exists only at its advertised /api/v1<path> mount. Anything calling the unadvertised nested URL gets a 404 after recompile + redeploy; switch to the documented root URL. Actions declared under the feature prefix are untouched, and a feature whose standalone actions are all root-mounted no longer emits an (empty) feature actions routes file.

Behavior change: PUBLIC inside or: arms admits anonymous callers

Access has always documented PUBLIC as working on every access node, but the emitted auth gate only honored it in a flat roles: [...] list. A tree like

access: {
  or: [
    { roles: ["PUBLIC"] },
    { roles: ["admin"] },
  ],
}

still answered anonymous callers with 401. The auth gate is now tree-aware and matches the documented semantics: a PUBLIC or: arm admits anonymous callers past the authentication gate (an and: group admits them only when every arm does), and the access / org / firewall layers still evaluate the full tree. One shared predicate now drives the emitted gates, the OpenAPI security blocks, and view routes (which gate per view on the view-effective access).

Worth a loud note: if you have a nested PUBLIC arm that was silently 401-ing, that endpoint becomes anonymously reachable after recompile — exactly what the config declares, but verify it's what you meant. PUBLIC audit logging applies to the newly-admitted routes as usual.

Realtime broadcasts now enforce requiredRoles and masking

A table's declared realtime gates weren't being applied to the generated CRUD broadcasts: realtime: { requiredRoles: [...] } and column masking were honored on the HTTP surface but not on the WebSocket fanout. The docs promised per-role, masked realtime delivery; the generated broadcasts now match it. Recompile and redeploy any project using realtime on role-gated or masked tables.

Generated realtime.insert / update / delete calls now carry:

  • targetRoles — the table's realtime.requiredRoles (post role- hierarchy + expansion), enforced per subscriber at fanout.
  • maskingConfig — serialized from the same effective masking (declared + auto-detected sensitive columns) the HTTP mask functions are generated from. What HTTP masks is what the WebSocket wire masks.

Live-view view_changes deltas honor the same table-level requiredRoles at fanout, so a role-gated table can't leak through a live view subscription either.

Notes:

  • Recompile suffices — no config change needed.
  • If you worked around this with hand-written realtime.* calls passing targetRoles / maskingConfig, those workaround broadcasts can be removed.
  • type: 'custom' mask functions can't ride the wire — the Broadcaster fails closed and broadcasts [REDACTED] for those columns.
  • Tables without requiredRoles or masking are byte-identical: the open broadcast default is unchanged.

Batch operations inherit the base operation's access

When a batch operation was declared without its own access, it didn't pick up the base operation's role requirement:

crud: {
  update: { access: { roles: ["admin"] } },
  batchUpdate: { maxBatchSize: 25 },   // ← no access of its own
}

A batch config declared without access now inherits the base operation's access at compile time — fail closed, so runtime, the OpenAPI spec, and the generated code all agree. Explicit batch access still wins (including per-side on upserts); a base op without access still yields an authenticated-only batch op. The flat update: { batch: true } shape and auto-promoted batch ops were never affected. Recompile and redeploy if you use the explicit-batch shape without per-batch access.

Record actions, PUT upserts, and legacy firewall arms fail closed

Three fail-closed hardening fixes, found and verified against the new conformance catalog (route × principal expectations derived from the compiler's own IR — see the conformance manifest). All apply on recompile + redeploy; no config change needed:

  • Record actions evaluate the role gate before fetching the record, so the denial response no longer varies with record state — a uniform 403 ACCESS_ROLE_REQUIRED. (Bulk actions already had this order; unsafe actions write their audit row on the pre-record denial.)
  • PUT upsert treats an out-of-scope id as a firewall miss (403 reveal / 404 hide, per your firewall error mode; a standard 207 miss entry on PUT /batch) instead of routing it into the create path. Genuinely new ids keep the create path.
  • Legacy (array-form) firewall arms deny cleanly when a bound context claim is absent at runtime (e.g. no active org), rather than surfacing a database error — the same deny-when-absent behavior the explicit all: / any: group forms already had.

Root-mounted standalone actions use the standard gate stack

Standalone actions mounted outside their feature prefix (path: "/reports/orders") were emitted through a separate code path that did not apply the full access / audit / scoped-database treatment that in-feature standalone actions receive. They now render through the same generator, so the same access checks, security-audit writes, and tenant-scoped database wrapping apply everywhere. If you use root-mounted standalone actions, recompile and redeploy. (Unifying the two code paths is also what surfaced the two breaking changes above.)

Namespace routing fixes

  • An action whose declared path exactly equals its namespace prefix is now claimed by the namespace like any nested action (Hono's /x/* mount already matched the bare /x) — it's served through the namespace's gate instead of carrying a second inline copy of the resolver.
  • A namespace with zero claimed standalone actions (e.g. record-based-only) no longer mounts its middleware at all — a dead gate can no longer intercept requests under its prefix.

Custom org roles work with Better Auth member management

Custom roles referenced in access trees (e.g. roles: ["support"]) are now auto-registered with the Better Auth organization plugin. They were already enforceable on routes but unprovisionable through Better Auth — invite-member / update-member-role rejected them with 400 ROLE_NOT_FOUND, so there was no supported way to actually give a member the role. Auto-registered roles carry member-equivalent permissions (this is about assignability; your access trees remain the authorization model). If you pass your own roles / ac to the organization plugin options, those win and auto-registration is skipped. Built-in, pseudo, and scope: roles are excluded; projects with no custom roles emit byte-identical output.

snake_case tables: dropped record actions and broken helpers fixed

Two fixes for tables declared with snake_case names (event_notes):

  • The generated .quickback/define-action.ts helpers imported the camelCase identifier while the generated alias file exports the verbatim declared name (export { event_notes }) — a TS2724 compile failure for any snake_case table with action files. The helpers now import the real export aliased to the camelCase local.
  • Record actions on a snake-declared table living in a kebab-named feature file (event-notes/actions/pin.ts) were not emitted — missing from both the generated routes and OpenAPI. They now emit and document consistently.

If you have snake_case tables with record actions, recompiling adds routes that previously didn't exist — review the new surface.

First-class MCP OAuth config: auth.oauth

The OAuth 2.1 surface behind agents.mcp.requireAuth (v0.42.0) gains a first-class config block — env-resolvable issuer / login / consent URLs, scopes, RFC 8707 audiences, and a dynamic-client-registration toggle:

auth: defineAuth("better-auth", {
  oauth: {
    enabled: true,
    issuerUrl: { env: "OAUTH_ISSUER_URL", fallback: "http://localhost:8787/auth/v1" },
    loginPage: { env: "OAUTH_LOGIN_URL", fallback: "http://localhost:8787/account/login" },
    consentPage: { env: "OAUTH_CONSENT_URL", fallback: "http://localhost:8787/account/consent" },
    scopes: ["openid", "profile", "email", "offline_access"],
    audiences: [{ env: "OAUTH_MCP_AUDIENCE", fallback: "http://localhost:8787/mcp" }],
    allowDynamicClientRegistration: true,
  },
}),

Setting it auto-enables the oauthProvider, jwt, and bearer plugins, emits the RFC 8414 / RFC 9728 discovery documents, and accepts the provider's JWKS-signed access tokens in the standard auth middleware. A new project hook, quickback/hooks/resolve-oauth-context.ts, lets you derive tenant fields (activeOrgId / activeTeamId / roles) from the validated OAuth identity. On Neon, the auth schema is now owned by the Better Auth CLI end to end (the compiler no longer emits its placeholder src/auth/schema.ts or src/lib/neon.ts). See OAuth Provider (MCP-ready) and Auth Hooks.

Neon RLS covers force-enabled plugin tables

On Neon, row-level-security policies are generated for the Better Auth tables. The list of tables receiving policies was derived from your declared auth config and didn't account for plugins the compiler force-enables automatically — so some auth-owned tables could be created without RLS policies. The list now derives from the fully resolved plugin set, so every generated auth table receives appropriate policies (self-scoped where there is a clear owner, service-role-only otherwise — fail closed). The RLS migration is content-addressed, so the update arrives as a new journaled entry on the next recompile + db:migrate. Neon projects should recompile and run db:migrate.

New compile reports: operations + conformance manifests

Every compile now writes two machine-readable reports next to openapi.json:

  • reports/operations.manifest.json — a stable, sorted index of every operation: { operationId, method, path, auth, feature, kind }, covering the resource, action, view, auth, realtime, storage, and FGA surfaces. An extend-only public contract — the recommended integration point for scripting against the generated surface.
  • reports/conformance.manifest.json — for each route × principal class, the expected outcome (allow / 401 / 403 / cross-tenant miss …), derived from the same IR the emitters consume. This is the artifact the conformance fixes above were found and verified against.

The OpenAPI generator itself now reads the same IR as the route emitters, so the spec follows the emitted surface by construction — including four accuracy fixes: PUT /{id} is documented only when actually enabled, the batch surface documents the emitter's auto-promotion + access inheritance, and views document their effective (view-level, falling back to read-level) access. See Operations manifest.

v0.45.0 — June 9, 2026

auth.jwt.revocationCheck: 'kv' — sub-TTL JWT revocation

The JWT fast-path trusts signed claims for their full lifetime — that's what makes it a fast-path. Until now the only revocation bounds were the TTL (plain claims) and the carry-forward cap (scope claims, v0.44.0). The long-promised 'kv' mode is now real on the Cloudflare runtime:

auth: { jwt: { revocationCheck: 'kv' } }
  • Per-principal revocation timestamps, not per-token denylists. A revocation event writes "everything for this principal minted before now is dead" to the project's existing KV binding (keys prefixed qbrev:) — nothing extra to provision. Entries self-expire after the maximum token lifetime, so the store never accumulates.
  • The bearer fast-path checks before trusting. A verified token whose iat (or sct, for the relationship-scope claim) predates the stored timestamp is rejected and falls through to session auth, which re-checks membership and re-mints — an active legitimate session recovers transparently. Costs 2+ edge-cached KV reads per bearer verify; the explicit trade the opt-in buys.
  • Written automatically where revocation actually happens: org-member removal and role change (Better Auth organizationHooks), and UPDATE/DELETE of scope-conferring relationship rows — through the same audit-wrapper write chokepoint Live Views uses, plus the single DELETE route (which has the row in hand but no .returning()). Revoke-on-touch: a false positive just forces a cheap re-prove.
  • User bans flip automatically. The Better Auth admin plugin's ban lands as a user-row update, so a generated databaseHooks.user.update.after hook stamps the user-level revocation the moment banned flips — every outstanding JWT dies on its next use, and the cookie/session path already rejects banned sessions, completing the lockout. When outbound webhooks are configured, the same hook emits a user.banned event so subscribed endpoints are notified.
  • App-code helpers for everything else: src/lib/jwt-revocation.ts exports revokeUserTokens / revokeMemberTokens / revokeScopeTokens (e.g. call revokeUserTokens from a custom revoke-all flow).
  • Fails open. A KV outage degrades revocation to the TTL / carry-forward-cap bound — it never locks every bearer out.

Bun/Node + 'kv' fails at compile time with a clear message (the store rides the Cloudflare KV binding; those runtimes own immediate-revoke via the cookie/session path). Default remains 'none'; without the flag the emitted middleware is byte-identical to v0.44.0.

Neon: npm run db:migrate now applies the RLS layer

The RLS SQL (helper functions, policies, triggers) used to be emitted as orphaned drizzle/migrations/0099–0102 files that no tool ever applied — db:migrate ran the schema migrations and silently skipped the security layer. The RLS document now rides the journaled drizzle set in quickback/drizzle/ as a content-addressed migration (<idx>_quickback_rls_<hash>), so one npm run db:migrate applies schema and security, in dependency order. The migration is idempotent (DROP POLICY IF EXISTS + re-create), so it converges over databases where the old files were applied by hand; an unchanged security config appends nothing (the journal stays byte-stable across recompiles), and a changed one appends a new content-addressed entry — append-only, like every other migration. Existing Neon projects: recompile + db:migrate, nothing breaks.

Neon: user_sessions is real, and the SQL layer speaks TEXT ids

get_active_org_id() — the helper every org-scoped RLS policy reads — was querying a public.user_sessions table that no migration created, and the helper layer was typed uuid against Better Auth's TEXT ids (org_abc123), which would throw on first contact. Now:

  • user_sessions is compiler-owned migration state (src/db/rls-support.schema.ts), created by the normal journaled flow with a FK to the users table — outside src/auth/schema.ts, which the Better Auth CLI rewrites.
  • Generated Better Auth session hooks mirror the session's active organization (and team, in teams mode) into it on create/update. Switching to "no org" nulls it through — SQL-side access revokes, fail closed.
  • The RLS helper layer (get_active_org_id, get_active_team_id, is_owner, the audit helpers) is TEXT-typed to match Better Auth ids.
  • Policies for Better Auth's own tables use the plural physical names the BA CLI actually generates on Neon, and BA tables no longer get Quickback audit triggers for columns they don't have.

Hard deletes wire into the audit pipeline correctly

Two codegen bugs around delete: { mode: 'hard' }:

  • Firewall auto-detection emitted an isNull(deletedAt) predicate for tables with no soft-delete column — every read on a hard-delete table referenced a column that doesn't exist.
  • Hard-delete routes import the logHardDelete audit helper, but the helper file was only emitted when the project also had unsafe or PUBLIC actions — a project with hard deletes and neither failed at deploy time. Hard deletes are now a first-class trigger of the security-audit pipeline (helper file, AUDIT_DB binding, env types, audit migration); on D1 that means auditDatabaseId is required, and the compile error says so.

Generated output is now typechecked — with the first crop of fixes

Every compiler change now compiles a matrix of fixture projects and runs tsc + wrangler deploy --dry-run against the generated output — the class of bug where the compiler emits syntactically valid TypeScript that fails downstream. The first runs caught real ones, all fixed here:

  • Neon: src/db/org.schema.ts re-exported tables that don't exist after the Better Auth CLI's plural rewrite; a missing AuthDatabase type export; transaction callbacks typed bare (tx) failing noImplicitAny; the auth middleware's member-table fallback hardcoded the singular name.
  • Embeddings: the queue consumer's Ai.run result and the optional EMBEDDINGS_QUEUE binding both failed strict checks — the route now returns a structured 503 when the producer binding is missing instead of crashing.
  • snake_case relationship tables: relationship/arrow emitters imported the camelCase identifier where the generated file exports the verbatim table name (export { event_guests }) — TS2724 for any snake_case relationship table. Imports now alias the real export (event_guests as eventGuests).

v0.44.0 — June 9, 2026

Scope carry-forward is now bounded (revocation fix)

The auth middleware's rolling refresh re-signs a verified scope claim on every authed call without re-probing the relationship — that's what lets a scoped session ride past the short JWT TTL with zero per-request DB cost. But unbounded, it defeated the revocation model the docs promised: a removed guest/staff who kept polling got a fresh TTL on every request, so "removal takes effect within one TTL" only held for idle clients, and the refresh also bypassed the ≤180s scope-token ceiling (it re-signed with the raw auth.jwt.expiresIn).

Scope tokens now carry a proof timestamp (sct), stamped exclusively by the proven mint sites (POST /scope/v1/enter and the namespace resolver, via the shared mint helper). The rolling refresh:

  • refuses once the claim's proof is older than the carry-forward cap (15 minutes) — past it, the token expires within one TTL and the client re-proves via enter or any minting namespace route, which won't re-mint a revoked role. Worst-case post-revocation window for an active client: cap + one TTL from the last real proof, instead of unbounded.
  • carries sct forward verbatim — the refresh can never restart the window without an actual relationship probe. Claims without sct (pre-0.44 tokens) fail closed and simply stop refreshing.
  • signs through the same ≤180s ceiling as the mint sites — an auth.jwt.expiresIn above the ceiling no longer leaks into scope-bearing tokens via the refresh path.

No client changes needed: tokens renew exactly as before inside the window, and re-entering is the existing flow. For tables where even the capped window is too wide, gate with a via arm (live relationship subquery per request) instead of a bare ctx.scope.<kind> arm; immediate revocation (auth.jwt.revocationCheck: 'kv') remains on the roadmap.

v0.43.0 — June 4, 2026

Better Auth 1.6

Generated projects are now pinned to Better Auth 1.6. The agents.mcp.requireAuth work in 0.42.0 surfaced a long-standing version drift: the compiler emitted better-auth: ^1.5.0, but @better-auth/oauth-provider resolves a tree that requires @better-auth/core ^1.6.14 and imports @better-auth/core/utils/host + /redirect-uri — subpaths that only exist in 1.6.x. The result was a wrangler deploy that failed at bundle time with Could not resolve … and never uploaded.

This release aligns the entire Better Auth dependency set to a coherent 1.6.9 floor so npm resolves one consistent tree:

  • better-auth, @better-auth/drizzle-adapter, @better-auth/api-key, @better-auth/passkey, @better-auth/oauth-provider^1.6.9 (generated projects, the baked Docker deps, and the bundled CMS/Account SPAs).
  • Added an explicit kysely ^0.28.16 pin: Better Auth 1.6's migrator imports DEFAULT_MIGRATION_TABLE / DEFAULT_MIGRATION_LOCK_TABLE from kysely but doesn't declare kysely, so the Worker bundle needs it pinned directly.

No code migration was required — Quickback already uses @better-auth/oauth-provider (not the now-deprecated OIDC Provider plugin), and emits no freshAge/SAML usage affected by 1.6's behavioral changes. Verified end to end: a requireAuth project and a normal project both npm install a clean 1.6.14 tree and bundle with wrangler deploy --dry-run with zero resolution errors.

Larger migration histories no longer hit the upload ceiling

Long-lived projects accumulate one full-schema drizzle snapshot per migration, and the whole cumulative meta payload (snapshots + journals + .sql, across both the auth and features dirs) is re-sent on every compile — even a no-schema-change one. Projects with deep migration histories were hitting the compiler's existingFiles size limit and failing to compile, with no schema drift and nothing to fix on their end.

The server-side ceilings are raised so this is no longer the binding constraint:

  • existingFiles payload: 4 MiB → 16 MiB
  • total request body: 8 MiB → 32 MiB (kept above the per-category limits so it doesn't trip first)

This is enforced entirely on the compiler, so no CLI upgrade is needed — the new headroom applies as soon as compiler.quickback.dev is on this release.

Custom response headers (security.headers)

The generated Worker emitted a fixed set of security headers (CSP, HSTS, X-Content-Type-Options, …) with no supported way to add your own — teams that wanted, say, an X-Server-Version build stamp on every response had to post-patch the generated middleware after each compile.

New security.headers sets arbitrary response headers natively. Values are static strings or templates interpolating per-deploy Worker env vars via the {{env.VAR_NAME}} token:

security: {
  headers: {
    // Both vars must be set, or the header is omitted entirely.
    'X-Server-Version': '{{env.SERVER_VERSION}}+{{env.SERVER_BUILD}}',
    'X-Powered-By': 'Quickback',
  },
}
  • Omit-if-unset — a header that references env vars is emitted only when every referenced var is set (non-empty) at runtime. A Worker that forgot to inject the var emits no header, never X-Server-Version: undefined. Static headers are always emitted.
  • The value is read from c.env per request, so it picks up whatever Worker var is injected at deploy time — nothing is baked at compile time.
  • Managed headers are rejected — names owned by a dedicated security field (CSP, HSTS, X-Frame-Options, Referrer-Policy, …) error at compile so the two emitters can't race. Malformed {{env…}} tokens error too, rather than leaking literal braces into the wire header.

Survives a clean quickback compile with no post-processing. See Security Headers → Custom response headers.

v0.42.0 — June 4, 2026

Interactive OAuth for the generated MCP server

MCP connectors (Claude, Cursor, …) could only connect to a generated /mcp endpoint anonymously — they never launched a sign-in flow, so auth-gated tools stayed invisible. The OAuth machinery was already present (Better Auth's oauth-provider ships /oauth2/authorize, /token, /register + PKCE under /auth/v1); the compiler just never let a connector discover or trigger it.

New agents.mcp.requireAuth makes the MCP surface authenticated-only:

agents: { mcp: { requireAuth: true } },

When enabled, the compiler:

  • Challenges unauthenticated /mcp with 401 + WWW-Authenticate: Bearer resource_metadata="…" (RFC 9728 §5.1) — the signal connectors use to start OAuth.
  • Force-enables oauthProvider + bearer, so /oauth2/authorize, /token, and /register (RFC 7591 dynamic client registration) exist with PKCE.
  • Delegates /.well-known/oauth-authorization-server to Better Auth's real metadata instead of a hand-rolled stub that advertised a non-existent authorization endpoint.
  • Adds the <origin>/mcp resource to validAudiences (RFC 8707) so the connector's access token isn't rejected.

Login and consent are served by your Account SPA, so users authenticate with whatever methods you already enabled. Default is unchanged (anonymous public tools); set requireAuth: true for servers meant for authenticated users.

v0.40.2 — June 3, 2026

Passwordless plugins can no longer bypass the signup gate

Better Auth's emailOtp and magicLink plugins auto-create an account when a code/link is verified for an unknown email — and that path is independent of emailAndPassword.disableSignUp. As a result, account.auth.signup: false closed POST /auth/v1/sign-up/email but left OTP / magic-link verification as an open back-door for account creation.

The compiler now emits each plugin's own disableSignUp flag, and new per-plugin options expose it directly:

auth: defineAuth("better-auth", {
  emailAndPassword: { enabled: true },
  plugins: {
    emailOtp: { disableSignUp: true },   // OTP sign-in only; unknown emails rejected
    magicLink: { disableSignUp: true },  // same for magic links
  },
}),

Both default to following the global gate: setting account.auth.signup: false (→ emailAndPassword.disableSignUp: true) now also gates the OTP and magic-link paths automatically. Set { disableSignUp: false } on a plugin explicitly to keep its sign-up open while password sign-up stays closed.

v0.40.1 — June 2, 2026

  • The generated wrangler.toml and Env type now emit the ASSETS binding type for projects that mount SPAs (CMS / Account), so handler code that references c.env.ASSETS typechecks.
  • Compiler Docker builds preserve their layer cache across rebuilds, cutting local recompile time.

v0.40.0 — June 2, 2026

R2 file storage is now presign-only by default. Adding defineFileStorage("cloudflare-r2") emits just the ctx.storage signer — no FILES_DB database, no /storage/v1/* endpoints, no files worker. You sign presigned URLs against a bucket and store references in your own table. The turnkey managed subsystem is still available, now behind managed: true.

This decouples the lightweight primitive from the heavyweight subsystem so you can add uploads without standing up a metadata database you don't use.

Presign-only by default; managed is opt-in

// Presign-only (default) — ctx.storage against a bucket, nothing else
defineFileStorage("cloudflare-r2", { bucketName: "my-app-media" })

// Managed subsystem — FILES_DB + /storage/v1/* + files worker
defineFileStorage("cloudflare-r2", { managed: true })
  • New projects can omit bucketName — it defaults to <project>-media, so setup is one wrangler r2 bucket create plus the R2 API-token secrets.
  • Existing projects set bucketName to a bucket they already have. Presign-only emits no R2 bucket binding and no FILES_DB binding — nothing to provision, nothing to migrate.

Per-call bucket + custom secret names

// Sign against a different bucket than the configured default
await storage.signPutUrl(key, { bucket: "my-app-public", contentType });

If your project already stores R2 credentials under different env-var names, map them so the signer reads yours:

defineFileStorage("cloudflare-r2", {
  bucketName: "my-app-media",
  presign: { accountIdEnv: "CF_ACCOUNT_ID", accessKeyIdEnv: "S3_KEY", secretAccessKeyEnv: "S3_SECRET" },
})

Fixes

  • maxFileSize now accepts a human string ("100mb", "5gb") as well as a number of bytes — previously a string emitted invalid TypeScript.
  • Presign-only no longer emits a stray FILES_DB [[d1_databases]] block (with an invalid database_id = "local-files") into wrangler.toml, which would have made wrangler deploy reject the worker.

v0.39.1 — June 2, 2026

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

v0.39.0 — June 2, 2026

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

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

ctx.storage — sign uploads from any action

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

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

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

Managed presign + confirm endpoints

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

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

Setup: R2 API token

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

wrangler secret put R2_ACCOUNT_ID
wrangler secret put R2_ACCESS_KEY_ID
wrangler secret put R2_SECRET_ACCESS_KEY

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

No raw-body action flag

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

Storage routes hardened

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

v0.38.1 — May 26, 2026

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

Neon compile output and local recompile state are now stable

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

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

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

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

That fixes the case where a namespace like:

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

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

Runtime behavior is now split cleanly:

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

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

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

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

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

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

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

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

Typed env + injected realtime in action handlers

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

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

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

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

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

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

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: attendeeOfscope.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 — an anyOf arm subsumed by a broader sibling.
  • AUTHZ_UNREACHABLE_ROLE / AUTHZ_UNREACHABLE_VIEW — dead scope config.
  • AUTHZ_NOT_OVER_RESOURCE — a not over a SQL site (the null-trap).
  • AUTHZ_PUSHDOWN_COST — an info report of each permission's subquery count and recursive-CTE depth.
  • AUTHZ_CAPABILITY_CEILINGerror — a resource grants a scope:<kind>:<role> read beyond its grants profile.

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

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

See Scopes & capability grants.

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 like eventHome at /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:attendee

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

v0.36.0 — May 25, 2026

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

id default moves to the column ($defaultFn)

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

Action db is typed on Cloudflare D1

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

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

access.resource gains an optional key

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

The long form now accepts key:

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

Public sharing

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

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

q-recruit showcase

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

v0.34.1 — May 24, 2026

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

Compiler now fails loud on drizzle migration state drift

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

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

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

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

New quickback migrations doctor CLI command

Two modes:

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

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

Upgrade path for projects in the broken state

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

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

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

v0.34.0 — May 23, 2026

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

Per-type email-OTP template overrides

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

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

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

Synthetic welcome routing — credential-row lookup

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

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

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

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

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

File transport mirrors existing slots

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

See AWS SES Plugin → Per-Type Template Overrides 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.

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

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

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

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

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

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

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

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

The preamble now reflects the actual surface:

hasEmbeddingshasCustomHandlersHeader
"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 too

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

Both new domains:

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

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

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

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

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

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

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

compiler.routes config block (scaffold — partial)

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

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 too

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

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

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

Admin URL namespace migration: /admin/v1/*/auth/v1/admin/*

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

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

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

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

What changes for callers:

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

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

Two compiler bug fixes

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

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 get id: text("id").primaryKey().$defaultFn(() => createId()) (or the matching shape for dbConfig.generateIdserial, uuid, prefixed).
  • Tables that already declare an id column (in any shape) are no-op — the injector is idempotent.
  • Tables with a .primaryKey() on a non-id column 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 .id regex to schema-aware metadata lookup, so tables with non-id PKs participate in FK display + relationship resolution.
  • The PK→unique-index rewrite lands — tables with explicit non-id PKs auto-keep their uniqueness as a constraint while gaining the auto-injected id.
  • 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.

features/feeds/actions/feed-moments.ts
// 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 (eventScopedefine-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 narrowed

A 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 by ctx.activeOrgId) does the tenant isolation; this lane just adds the role precondition on top.
  • { team: true } — passes when ctx.activeTeamId is set. The loads-table firewall (which scopes by team for team-scoped tables) does the actual events.teamId === ctx.activeTeamId match 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 rejectedctx.<exposeAs> must always hydrate the same table regardless of which lane matched.
  • namespace.loads that 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: or exposeAs: — if attendeeOf loads events as ctx.event and organizerOf loads agendaItems as ctx.agendaItem, downstream handlers can't predict what's in ctx.<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 declared authz.relationships[*] entry, and that relationship must declare loads + exposeAs — without loads there'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 :param segment — middleware runs before input parsing, so the relationship key comes from c.req.param().
  • Same-name conflict between defineTable({ namespace }) and authz.namespaces.<sameName> is rejected. Pick one declaration site.
  • Ambiguous prefixes like /a/:x and /a/:y (same shape, different :param names) error at compile time. Strict super/sub-set relationships (e.g. /event/:e and /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 to quickback.config.ts.
  • Use authz.namespaces when the namespace spans features. The declaration sits next to your authz.relationships and authz.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-agenda emitted into both src/index.ts (without the namespace resolver) and the synthetic file (with it). The plain root copy in src/index.ts won every URL match and ctx.<exposeAs> was never hydrated — every request 500'd with Cannot read properties of undefined (reading 'id'). extractStandaloneActions now 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'". planRelationshipResolver now receives the namespace's via and 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 through qb-do-${className} + new_sqlite_classes. Separate-mode was still hardcoded tag = "v1" + new_classes, so flipping realtime: { 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 emit tag = "qb-do-Broadcaster" + new_sqlite_classes.
  • Batch handler id arrays type-derive from the input parser. Pre-fix, validIds, recordMap, and validEntries in bulk-delete / bulk-update / bulk-upsert were hardcoded string[] / Map<string, any> even when the table's PK column was integer("id") — 274× TS2769 in projects mixing string and integer PKs. Handlers now declare type IdT = typeof ids[number] (or typeof 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.
  • ctx in action handlers is now AppContext, not any. Both StandaloneExecuteParams.ctx and RecordExecuteParams.ctx in the generated <feature>/.quickback/define-action.ts are typed AppContext. AppContext keeps its [key: string]: any index 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 AppContext in every emitted route handler. Pre-fix, const ctx = c.get('ctx') returned any, so ctx.roles?.some(r => …) left the callback parameter implicitly any — 16× TS7006. Now ctx.roles is string[] | undefined, r is string, no callback annotation needed.

Deferred (tracked, not blocking)

  • Per-namespace narrow typing of ctx.<exposeAs> (so ctx.event resolves to typeof events.$inferSelect instead of any). The compiler emits a typed cast at the execute() 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 (one define-action.<namespace>.ts per 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 the authz.roles via: validation, same layer, same error format.
  • Relationship-from tables can't opt out. The doc at define/types/authz.ts:73-74 always said a relationship from: table with firewall: { exception: true } "refuses to compile" — that's now enforced. Without this check, every via: subquery that pivots through an exception-marked table loses tenant isolation and can return rows from other tenants.
  • allowResourceMigration is now mandatory when the relationship's resource.column is on a writable update endpoint. A guest doing PATCH /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.column auto-derives from FKs. Omit it and the compiler scans the relationship's from: table for foreign-key columns, drops the subject.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.requiredRoles accepts authz roles. Previously a relationship-role in realtime.requiredRoles fell 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:

  1. 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).
  2. Skips the inline relationship resolver in every per-action handler under the prefix — the middleware already ran it.
  3. Hydrates ctx.event once per request and threads the typed cast into every namespace action's execute() call.

Validation guardrails:

  • namespace.via must point at a declared authz.relationships[*] entry.
  • The referenced relationship must declare loads + exposeAs — without loads there's nothing for the middleware to hydrate, and the namespace gate would be indistinguishable from a per-action access rule.
  • namespace.prefix must contain at least one :param segment — the middleware reads the relationship key from c.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 defineTable declaration; 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.column writable on the resource's UPDATE endpoint now errors unless allowResourceMigration: true is 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: bun and supabase-edge are no longer supported. The only runtime is cloudflare. Local development for compiled projects has always used wrangler dev — that's unchanged.
  • Databases: supabase, libsql, better-sqlite3, bun-sqlite are removed. Use cloudflare-d1 for SQLite at the edge or neon for PostgreSQL via Hyperdrive.
  • Auth providers: supabase-auth is removed. better-auth is 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 the apps: 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 the compiler/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', ... } in quickback.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 at quickback/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). Set security: false to 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 in access: { 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, neon

To migrate:

  1. From Supabase: switch providers.database to cloudflare-d1 or neon, and providers.auth to better-auth. Your defineTable() definitions transfer as-is; the security DSL (firewall, access, guards, masking) compiles to Better Auth + Drizzle queries instead of RLS policies.
  2. From Bun runtime / bun-sqlite: switch providers.runtime to cloudflare and providers.database to cloudflare-d1. Local dev becomes wrangler dev.
  3. From libsql / Turso: switch to cloudflare-d1 or neon. Both run natively from Cloudflare Workers via the right binding.
  4. From template: 'nextjs': deploy Next.js separately and call the Quickback API, or drop a static export into apps:.

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 an ownerId column auto-resolve to firewall: [{ field: 'ownerId', equals: 'ctx.userId' }] and have ownerId auto-stamped on insert.
  • The generated OWNER_SCOPE_RULES in src/lib/scoped-db.ts is seeded with ownerId instead of userId. Schemas that don't explicitly name userId in a firewall: block no longer trigger the global userId-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 userId column. The warning recommends renaming the column to linkedUserId (or similar) if it's informational, or declaring firewall: [{ 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 at ownerId and the explicit-firewall escape hatch. The previous error wording (which assumed ownerId was business-logic) is removed.

What stayed the same

  • Better Auth's own internal tables (session, account, member, passkey, subscriptions) still use userId by 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 USER pseudo-role validator behaviour is unchanged; only the error-message wording flips to lead with ownerId and list userId as 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:

  1. Recommended: rename the column to ownerId in the schema. Cleanest semantics — readers immediately understand the column is access-control.
  2. Explicit firewall: keep the userId column and add firewall: [{ 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. Future pnpm dlx shadcn@latest add <name> runs from packages/ui/.
  • packages/cms/app/app.css and packages/account/app/app.css reduce to two imports plus SPA-specific layers (CMS keeps its AG Grid + richtext-display overrides). packages/{cms,account}/components.json deleted.

Components, icons, deps

  • All 22 shadcn primitives regenerated against @base-ui/react (@radix-ui/* removed everywhere). Drawer stays on vaul — base-ui has no Drawer primitive.
  • Icons migrated from lucide-react to @remixicon/react line variants across ~100 source files (Plus → RiAddLine, ChevronDown → RiArrowDownSLine, X → RiCloseLine, etc.). lucide-react removed from all package.json + deps-spa-* files.
  • pnpm.overrides pin @types/react ^19.2.0 workspace-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 bump shadcn to ^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). Existing Dispatch<SetStateAction<string>> setters reject null — wrap callbacks defensively: onValueChange={(v) => v != null && setX(v)}.
  • <DropdownMenuContent forceMount> removed (base-ui has no forceMount). Use base-ui's keepMounted on the Popup/Positioner if you need to preserve unmounted state.
  • Component sub-names shifted (Dialog.ContentDialog.Popup, Dialog.OverlayDialog.Backdrop). Data attributes are now data-open / data-closed (no more data-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.bak

If 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" (from listUserTeams) and "Other teams" so non-admins land on what's relevant. Create / rename / delete from the list, gated on team:create / team:update / team:delete.
  • Team detail (/$slug/teams/$teamId) — member roster with add / remove, joining listTeamMembers against the org's full member list for display.
  • Active-team picker in the org header — calls setActiveTeam and exposes the choice via OrganizationContext.activeTeamId so downstream routes (and the BA session.activeTeamId server-side) stay in sync.
  • Team-aware invitesInviteMemberDialog shows an optional team picker when teams are on and forwards teamId to inviteMember, 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.tsx tab gates → can('invitation', 'create'), can('organization', 'update')
  • members.tsx action 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 enforcement
  • invitations.tsxcan('invitation', 'create' | 'cancel') gates the Invite button, resend, and cancel separately
  • settings.tsxcan('organization', 'update') for the form, can('organization', 'delete') for the danger zone (was owner-only)
  • teams.tsx / teams.$teamId.tsxcan('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 checkSlug flow (mirroring the create-org form); on rename, the SPA navigates to /${newSlug}/settings and the layout re-fetches with the new slug.
  • Metadata — single Record<string, any> | null JSON 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 = true route in wrangler.toml for the primary hostname and every alias
  • adds each hostname to Better Auth's TRUSTED_ORIGINS (when trustedOrigins is left unset)
  • widens the CSP connect-src to 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.

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 start

start 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 whoami reads the stored tier from your credentials.
  • Pro upsell copy in quickback login is 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.

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

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 feature
  • cms.placement: "tables" with cms.tables scopes it to specific tables
  • cms.hidden: true keeps 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.enabled
  • emailVerification → emailAndPassword.requireEmailVerification
  • passkey / 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, use USER, or split the route).
  • createRealtime imports without realtime: true enabled 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

  • SYSADMIN is now recognised by the standalone-action org gate's pseudo-role matcher.
  • Apex / and /account/login get fallback redirects to auth.loginUrl when 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 queue export 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.
  • db references in single-record and bulk action handlers now resolve correctly during pre-fetch.
  • PartyServer auth gates pass the in-scope db handle 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/call URLs are no longer double-prefixed for action tools.
  • Auth middleware accepts API keys via Authorization: Bearer in addition to x-api-key and query parameters — the channel MCP clients use exclusively.
  • auth.roleHierarchy is 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_URL env var renamed to BROADCAST_URL.
  • SPA helpers getRealtimeWsUrl() / getRealtimeTicketUrl() renamed to getBroadcastWsUrl() / getBroadcastTicketUrl().
  • appConfig.routes.api.realtime renamed to appConfig.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:

TriggerAuto-addedDirective
cms: true OR account: true'unsafe-inline', https://fonts.googleapis.comstyleSrc
cms: true OR account: truehttps://fonts.gstatic.comfontSrc
csp set (always)@trustedOriginsconnectSrc
Bundled SPA enabledSPA-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 /mcp no longer enumerates tool names.
  • Internal flavor text is removed; annotations.destructiveHint and _unsafe remain — 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: [...] }.
  • id claims format: "uuid" only when the project's generateId actually 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 QuickbackErrorCode enum values (DB_NOT_NULL_VIOLATION, DB_UNIQUE_VIOLATION, etc.) so generated SDKs can exhaustively switch on code. 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.tsrecord, input, and audit fields are typed without manual annotations.

Discovery rules:

  • actions/<name>.ts binds to the feature's primary table.
  • actions/<table>/<name>.ts binds 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>.ts containing both defineTable and defineActions(<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.listread.access (plus read.pageSize / read.maxPageSize)
  • crud.getread.access (single-record GET inherits automatically)
  • Top-level views: {...}read.views: {...}
  • crud.list.fields / crud.get.fields → declare a named view under read.views and 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.

OldNew
VITE_API_URLVITE_QUICKBACK_API_URL
VITE_ACCOUNT_URLVITE_QUICKBACK_ACCOUNT_URL
VITE_APP_URLVITE_QUICKBACK_APP_URL
VITE_CMS_URLVITE_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, and wrangler.toml files 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 DetectedSELECT / UPDATE / DELETEINSERT
organizationIdAdds WHERE organizationId = ?Auto-injects organizationId from context
ownerIdAdds WHERE ownerId = ?Auto-injects ownerId from context
deletedAtAdds 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 / deletedBy columns (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=true returns total matching records in X-Total-Count
  • Full-text search?search=keyword searches 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.

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.
  • CLIquickback create, quickback compile.
  • Better Auth integration — organizations, roles, sessions.
  • Drizzle ORM — schema-first with automatic migrations.
  • Cloudflare D1 — split database support (auth + features).

On this page

v0.52.1 — July 17, 2026CLI: cold-start tolerance for the cloud compilerv0.52.0 — July 17, 2026Five action primitives: afterCommit, realtime emit, transitions v2, refs, bundlesCLI device login fixed: /cli/authorize now claims the code before approvingGenerated builds no longer rewrite the auth schemaNon-cross-tenant unsafe actions preserve delegated RLS claimsAtomic email-OTP auth routing no longer recurses on ordinary requestsGenerated Better Auth CLI dependencies no longer retain vulnerable LodashPostgres constraint replacements apply in dependency-safe orderSchema registry output is byte-reproducibleGenerated auth and discovery surfaces now remain truthful and type-safeEmail-OTP activation can be admitted atomically in the API layerNeon migrations can declare cross-schema foreign keysCompile completion steps honor the Neon connection modeCloudflare email delivery and named Neon targets fail visibly and deploy explicitlyContract v2 auth and Neon authority boundaries are fail-closedStandalone action routes preserve their exact authority contextParameterized actions keep complete v2 schema harvestsAction-schema harvest uses the generated runtime dependency treeContract v2 delegated principals and RLS-only action tablesAction execute helpers preserve their typed boundaryNeon Hyperdrive is now the only Worker database lane, including Better AuthPublic contract v2 now compiles as one coherent API/auth surfaceComplete, isolated Wrangler targets for named Neon environmentsLocal compiler trust boundary and deterministic Drizzle commandsAction rate limits now emit — documented since the rate-limit pillar, wired nowBetter Auth CLI pinned and shipped as a generated devDependencyCompile warning when an action's input schema degrades to untypedAuth docs: JWT security posture stated up frontNeon via-user reads: generated list handlers now typecheck against the concretely-typed service handlews-ticket minting now accepts all three authorization vocabulariesNeon hyperdrive: generated env.d.ts now types the HYPERDRIVE bindingNeon hyperdrive: typed action db accepts explicit values for defaulted columnsProduction 500 bodies are now generic — error internals stay in the logswrangler.toml now declares every binding the generated code referencesSecurity: three fail-closed fixes in the generated runtimeNeon reads through user-anchored relationshipsNeon interactive transactions via HyperdriveNeon migration & environment toolingNeon PostgreSQL reaches deploy parity with D1 (v0.50)Realtime identity tags — one send reaches a person on either auth door (v0.49)Realtime authorization lease — hibernated sockets no longer outlive revocation (v0.49)Realtime broadcasts are fail-closed — explicit per-resource audience (BREAKING)Deterministic codegen outputacceptScope: one namespaced handler for the user AND the sessionless principalSessionless scope mint: ctx.mintScope + subject: 'supplied' \| 'both'MCP OAuth: split authorization-server / resource-server (multi-app SSO)MCP tools: callable path params + typed input schemasScaffold surfaces MCP/OAuth + secrets opt-insCustom secrets via bindings.secretsAuth: resolve-email-context.ts hook for per-recipient OTP contextCodegen fixesRealtime: identity-gated Durable Object rooms (roomIdEquals)OAuth provider: spurious authorization-server warning silenced by defaultdefineSchedule — cron jobs from a single sourceRate limiting — the fifth security pillarEnvelope encryption — q.text().encrypted() (encryption pillar, Phase 1)Postgres-style table triggers (defineTable / feature triggers:)v0.47.0 — June 11, 2026Breaking: zero-false-positive analyzer checks are now compile errorsCLI: live views now compile through the CLICLI: deterministic compile output across machinesFixesv0.46.0 — June 11, 2026Breaking: "no organization context" responses unified on HTTP 403Breaking: root-mounted actions lose their nested alias URLsBehavior change: PUBLIC inside or: arms admits anonymous callersRealtime broadcasts now enforce requiredRoles and maskingBatch operations inherit the base operation's accessRecord actions, PUT upserts, and legacy firewall arms fail closedRoot-mounted standalone actions use the standard gate stackNamespace routing fixesCustom org roles work with Better Auth member managementsnake_case tables: dropped record actions and broken helpers fixedFirst-class MCP OAuth config: auth.oauthNeon RLS covers force-enabled plugin tablesNew compile reports: operations + conformance manifestsv0.45.0 — June 9, 2026auth.jwt.revocationCheck: 'kv' — sub-TTL JWT revocationNeon: npm run db:migrate now applies the RLS layerNeon: user_sessions is real, and the SQL layer speaks TEXT idsHard deletes wire into the audit pipeline correctlyGenerated output is now typechecked — with the first crop of fixesv0.44.0 — June 9, 2026Scope carry-forward is now bounded (revocation fix)v0.43.0 — June 4, 2026Better Auth 1.6Larger migration histories no longer hit the upload ceilingCustom response headers (security.headers)v0.42.0 — June 4, 2026Interactive OAuth for the generated MCP serverv0.40.2 — June 3, 2026Passwordless plugins can no longer bypass the signup gatev0.40.1 — June 2, 2026v0.40.0 — June 2, 2026Presign-only by default; managed is opt-inPer-call bucket + custom secret namesFixesv0.39.1 — June 2, 2026v0.39.0 — June 2, 2026ctx.storage — sign uploads from any actionManaged presign + confirm endpointsSetup: R2 API tokenNo raw-body action flagStorage routes hardenedv0.38.1 — May 26, 2026Neon compile output and local recompile state are now stableShared-target namespace hydration now works for bulk-grant lanesPlatform role split: appmanager for control-plane, sysadmin for cross-tenantTyped env + injected realtime in action handlersgenerateId: "cuid" docs now reflect the split runtime/schema behaviorv0.38.0 — May 26, 2026Scope refresh & mintScope — per matched laneNamespace-backed ws-ticket — realtime, per matched lanev0.37.0 — May 26, 2026Named permissions (authz.permissions) — M1FK-traversal arrows (authz.arrows) — M2Compile-time authz analyzer + diagnostics — M4Aggregate-only views — M0.1Scope leftovers — M0.3Namespace scope-minting (mintScope) — §5.3v0.36.3 — May 25, 2026Fix: /auth/v1/* handler forwards executionCtx so OTP emails actually sendv0.36.2 — May 25, 2026Fix: a firewall isNull predicate on a non-deletedAt column no longer globalizes as soft-deleteFix: list route no longer hard-requires an org when the firewall makes org optionalv0.36.1 — May 25, 2026authz.scopes — scoped roles via a relationship probeScoped-role name sugarv0.36.0 — May 25, 2026id default moves to the column ($defaultFn)Action db is typed on Cloudflare D1access.resource gains an optional keyv0.35.0 — May 25, 2026Relationship-based access control (authz.fga)Public sharingq-recruit showcasev0.34.1 — May 24, 2026Compiler now fails loud on drizzle migration state driftNew quickback migrations doctor CLI commandUpgrade path for projects in the broken statev0.34.0 — May 23, 2026Per-type email-OTP template overridesSynthetic welcome routing — credential-row lookupNew recognized hook: after-admin-user-create.tsFile transport mirrors existing slotsv0.33.6 — May 22, 2026Schema-emitted createId() now ships with a polyfill (no missing import)queue-consumer.ts header reflects what the file actually doesSymmetric embedding detection prevents silent driftv0.33.5 — May 22, 2026URL :id params coerce to Number when the PK column is integerAudit-field injection now runs for every dialectv0.33.4 — May 21, 2026OpenAPI emits CRUD endpoints only when the route emitter registers themLegacy D1 audit-timestamp migrations rewritten in placeD1 helper warning is now actionableReference: account.appUrl / account.tenantPatternv0.33.0 — May 21, 2026Dedicated hostnames for /auth/v1/* and /api/v1/* (Shape 1)quickback.{baseDomain} codified as the always-present unified backendcompiler.routes config block (scaffold — partial)v0.32.0 — May 21, 2026Single-origin path-routed architectureAdmin URL namespace migration: /admin/v1/*/auth/v1/admin/*Two compiler bug fixesv0.29.0 — May 19, 2026Opt-in id auto-injection (compiler.injectId: 'auto')Why this is opt-in (and what v0.30 changes)v0.28.0 — May 19, 2026INTERNAL pseudo-role: server-only schemas, unforgeable from HTTPReserved-name fix: SYSADMIN and INTERNAL added to ALWAYS_RESERVEDPer-table OWNER_SCOPE_RULES: explicit owner predicates no longer over-scopev0.27.0 — May 19, 2026Narrow ctx.<exposeAs> typing for namespace-scoped actionsWhere the file livesOpt-in, no breaking changeCompile-time advisorySide fixOut of scopev0.26.0 — May 19, 2026Bulk-grant namespace lanes: org admins and team members in one lineWhat each bulk-grant lane doesWhy this mattersRich deny payloadsCompile-time guardsv0.25.0 — May 19, 2026Multi-lane namespace gates: more than one way to be authorizedBackward compatibleCompile-time guardsFollow-up: v0.26 bulk-grant lanes (shipped same day — see above)v0.24.0 — May 19, 2026Cross-feature namespaces: one hoisted gate, many featuresDiscovery is path-prefix-onlySource-feature stays the action's home for metadataWorker-root mount orderCompile-time guardrailsPicking the right declaration siteMigrationFixes shipped alongsideDeferred (tracked, not blocking)v0.23.0 — May 19, 2026Authz relationships: compile-time guarantees, less boilerplate, hoisted middlewareCompile-time guarantees (the authz firewall parity)Less boilerplate: auto-derivation + realtime resolverRecord-based actions can finally use relationship-rolesTyped ctx.<exposeAs> in action handlersNew: defineNamespace — one mount, one resolver, many actionsMigrationWhy this mattersv0.22.0 — May 19, 2026Quickback Stack is Cloudflare-only: one runtime, two databases, one auth providerWhat's been removedAccount UI: one delivery modeVITE_AUTH_ROUTE hardcoded to QuickbackNew: every config option is now discoverableMigrationWhy this mattersv0.21.0 — May 19, 2026Auto-firewall column swap: ownerId is now canonical on feature tables, userId warnsv0.20.0 — May 19, 2026CMS + Account SPAs: shadcn base-ui migration, central Tailwind configSingle source of truth in packages/ui/Components, icons, depsBackwards-compat asChild shimCaller-side API changes worth notingAdding a new primitive after this releasev0.18.1 — May 18, 2026Account SPA — teams, active-team picker, permission-aware UITeams (opt-in)Permission-aware UI via checkRolePermissionEditable slug + metadata on organization settingsAuth client wiringv0.18.0 — May 17, 2026config.apps — mount user-authored SPAs on dedicated hostnamesCross-subdomain cookie inference handles apex aliasesv0.17.2 — May 15, 2026customPlugins escape hatch on defineAuth("better-auth", …)v0.17.1 — May 14, 2026quickback start — interactive onboardingNamed views on the free tierquickback init removedMinorv0.16.10 — May 14, 2026D1-specific safety pass on generated migrationsv0.16.9 — May 13, 2026Resource-scoped realtime ticketsUnified broadcast targetingv0.16.8 — May 13, 2026One auth transport per requestCookie-backed CSRF gateFirst-class Wrangler observabilityv0.16.7 — May 12, 2026Bearer wins over cookieBearer plugin defaults to requireSignature: truev0.16.5 — May 12, 2026Flat write DSLCMS placement for multi-table feature actionsopenapi.types accepts a string arrayStable artifact orderingv0.16.4 — May 12, 2026Drizzle snapshot integrityaccount.auth.* drives the server, not just the SPAv0.16.3 — May 9, 2026Standalone action mount orderv0.16.1 — May 9, 2026Cloudflare ASSETS canonicalizationaccount.customLoginProject-level realtime defaultsCompile-time validatorsDrizzle metadata hardeningMinorv0.16.0 — May 9, 2026Agent Auth ProtocolBearer-only on /mcp and the agent-auth surfaceAuth DSL gains first-class plugin entriesqueue-consumer.ts gatingv0.15.0 — May 7, 2026Sysadmin tier for the CMSDurable Object [[migrations]] order is preservedquickback/public/ — static-asset passthroughMinorv0.14.7 — May 7, 2026Codegen fixes for projects with non-id primary keys and DO-only featuresemailOtp + AWS SES is OTP-onlyv0.14.6 — May 7, 2026MCP transport correctnessv0.14.5 — May 7, 2026MCP environment threadingv0.14.4 — May 7, 2026via: firewall predicates pull required importsv0.14.3 — May 7, 2026Relationship-role table re-exportsv0.14.2 — May 7, 2026Historical DO [[migrations]] are preservedv0.14.1 — May 7, 2026Bulk action variants in openapi.jsonCross-tenant "my events" patternsv0.14.0 — May 7, 2026Relationship-based authorizationBulk variants on record-based actionsAggregations on read viewsv0.13.5 — May 6, 2026Object-form auth.plugins config is honoredroles: ["PUBLIC"] allowed on writes and actionsv0.12.0 — May 5, 2026Per-room authorization for PartyServerv0.11.1 — May 5, 2026PartyServer auto-mount is secure by defaultv0.11.0 — May 5, 2026OAuth Provider (MCP-ready)API keys via query parameterPartyServer rooms + realtime URL splitv0.10.20 — May 4, 2026In-worker Durable Objectsv0.10.18 — May 2, 2026experimentalRemote — share deployed D1 with wrangler devGoogle OAuth + signup ToSCLI compile auth gatev0.10.15–v0.10.17 — May 2, 2026CSP auto-configv0.10.14 — May 1, 2026auth.jwt configAdmin MCP endpoints filteredDeclarative security configHSTS skipped on localhostv0.10.13 — May 1, 2026Clickjacking defence on every Workerv0.10.11 — May 1, 2026MCP tools/list is access-filteredv0.10.10 — April 29, 2026Reserved keys in access.record are rejectedv0.10.8 — April 29, 2026Audit logging on Postgres targetsauditDatabaseId is a compile-time requirementAction input → OpenAPI / MCPOpenAPI fidelityMinorDSL: one file per action, multi-table per featureOne file per actionMulti-table actions per featureFirewall: named-scope shapev0.10.0 — April 26, 2026q.scope() — single-declaration tenant scopingmasking[col].query.roles — unified filter / sort / search gatev0.9.0 — April 25, 2026Unified read: pipelinePre-record access orderingTransactional batch operationsv0.8.7 — April 17, 2026Hono CVE bumpCloudflare Email Sending in public betaDependency sweepv0.8.5 — April 15, 2026userRole access primitiveCMS admin gate enforced server-sideBetter Auth 1.5 + @cloudflare/workers-types compatibilityv0.8.4 — April 14, 2026generateId: "short" strategyv0.8.2 — April 11, 2026Catch-all VITE_QUICKBACK_URLv0.8.1 — April 9, 2026Role hierarchy with + suffixv0.8.0 — April 9, 2026Breaking: VITE URL environment variables namespacedCMS access controlv0.5.11 — February 24, 2026Mandatory unsafe action audit trailSecurity contract report artifactsHeadless Drizzle rename hintsMinorv0.5.8 — February 14, 2026Quickback CMSv0.5.7 — February 12, 2026Scoped database for actionsCascading soft deleteAdvanced query parametersv0.5.5 — February 5, 2026OpenAPI spec generationBetter Auth pluginsSecurity headersv0.5.4 — January 30, 2026Account UIWebhook systemRealtime & vector searchv0.5.0 — January 2026Initial release