Quickback Docs

v0.32 – v0.39

Quickback release notes for v0.32.0 through v0.39.1 (May – June 2026).

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:

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

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

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

Managed presign + confirm endpoints

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

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

Setup: R2 API token

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

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).

On this page

v0.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 fixes