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:
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 declaredcontentType+sizeagainst 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_KEYaws4fetch 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.tomlno longer emits a stray[[d1_databases]]block for Neon projects- repeated local Neon compiles now upload and sync the root PostgreSQL Drizzle
state correctly, so
drizzle-kitcan report "No schema changes" instead of regenerating duplicate0000_*.sqlfiles - stale root Drizzle migrations from earlier bad compiles are pruned during the compile/sync path
Shared-target namespace hydration now works for bulk-grant lanes
Bulk-grant namespace callers ({ roles: [...] }, { team: true }) now inherit
the namespace's shared hydration target when every relationship lane points at
the same resource load shape.
That fixes the case where a namespace like:
via: ['attendeeOf', 'organizerOf', { roles: ['admin', 'owner'] }]would authorize an org admin through the bulk lane but leave ctx.event
undefined, even though the sibling relationship lanes all hydrated the same
events row.
Runtime behavior is now split cleanly:
- Shared target — if all hydrating lanes share the same
loads,exposeAs, and resource key, hydration runs unconditionally after the gate passes, so bulk callers get the samectx.<exposeAs>as relationship callers - Distinct targets — if lanes hydrate different tables / keys / ctx slots, hydration stays per-matched-lane to avoid cross-table false 404s
Standalone action typing now matches that runtime behavior too: shared-target hydration remains non-optional, while distinct-target multi-lane hydration stays optional.
Platform role split: appmanager for control-plane, sysadmin for cross-tenant
Quickback no longer treats user.role === "admin" as the platform-wide role.
The control-plane role is now user.role === "appmanager" (CMS shell, Better
Auth admin endpoints, support tooling), while user.role === "sysadmin"
remains the only true cross-tenant data-plane tier.
This also retires the ADMIN pseudo-role. Any roles: ["ADMIN"] usage now
fails the compile with migration guidance:
userRole: ["appmanager"]for platform control-plane accessroles: ["admin"]for org-membership admin accessroles: ["SYSADMIN"]for true cross-tenant access
Runtime behavior now matches that split end-to-end:
- Better Auth's admin plugin is generated with
adminRoles: ["appmanager", "sysadmin"] - CMS / OpenAPI / auth-admin surfaces gate on
appmanager(and admitsysadminwhere appropriate) - unsafe cross-tenant actions and tenant-bypass helpers require
sysadminonly
Typed env + injected realtime in action handlers
The generated <feature>/.quickback/define-action.ts helper now types action
env from the project's runtime bindings surface instead of any, so custom
bindings like R2 buckets, queues, Durable Objects, auth env vars, and split DB
bindings no longer need (env as any) casts inside action handlers.
When realtime is enabled in config, action handlers also receive a typed
realtime helper directly in execute(...). It exposes the same
insert/update/delete/broadcast methods as createRealtime(env), but without
the extra import or hand-rolled factory call in every custom action.
This is a DX-only change. The auto-broadcast boundary is unchanged: generated CRUD routes still broadcast automatically, while custom action writes still broadcast explicitly.
generateId: "cuid" docs now reflect the split runtime/schema behavior
Quickback's cuid strategy is now documented as the split path it actually is:
compiler-managed schema auto-injection keeps the createId() call site but may
back it with a file-local crypto.randomUUID() polyfill, while import-capable
runtime paths still emit real createId() usage.
That matters most for compiler.injectId and other schema-emission paths that
run in sandboxes where project node_modules are not visible. The generated
schema can safely keep:
id: text("id").primaryKey().$defaultFn(() => createId())while satisfying the reference via:
const createId = () => crypto.randomUUID();instead of importing @paralleldrive/cuid2 inside the schema-generation
environment.
Important nuance: this is not a guarantee that every generated id is a true
cuid2 string. Auto-injected schema paths may produce UUID-shaped strings
through the polyfill, while runtime paths that can import helpers may still use
real createId(). If you need strict cuid2 shape everywhere, explicitly import
createId from @paralleldrive/cuid2 in the authored schema file; the
compiler preserves that import and skips the polyfill.
v0.38.0 — May 26, 2026
Per-matched-lane authorization for multi-lane namespaces — across both the
REST scope refresh/mint and the realtime ws-ticket. A namespace gated by several
via: lanes (attendees or organizers or contractors or org admins) now
confers the scope role of whichever lane the caller actually matched, instead
of suppressing the grant. This retires the v0.37.0 single-conferring-lane
limitation.
Scope refresh & mintScope — per matched lane
A multi-lane via: now refreshes and mints the matched lane's role:
attendeeOf → scope.event.roles = ['attendee'], organizerOf → ['organizer'].
The resolver records which lane opened the gate (first-match-wins), so a caller
can only ever carry the role they proved this request. A bulk-grant lane
({ roles: [...] } / { team: true }) still confers no scope role — it
authorizes via the caller's org role, with nothing to stamp. Previously any
multi-lane / bulk / mixed via: suppressed the stamp and mint entirely; that
restriction is lifted. See Scopes.
Namespace-backed ws-ticket — realtime, per matched lane
realtime.wsTicket accepts a namespace reference, so the
/broadcast/v1/ws-ticket route authorizes through that namespace's full
multi-lane via: and issues a per-matched-lane WebSocket ticket — the socket
gate and the REST gate stay identical, from a single source of truth.
realtime: {
wsTicket: { namespace: "eventScope", requestField: "eventId", scopeTable: "events" },
},An attendee gets an attendee ticket, an organizer an organizer ticket, and an
org admin who reaches the room through the bulk lane now gets a ticket scoped to
their org/membership roles — closing the gap where the single-role form 403'd
a legitimate admin and left their realtime socket dead.
Tenant-bound, fail-closed. Before minting, the route loads the scopeTable
row through its own firewall, so a ticket can only target a room whose resource
resolves under the caller's tenant — an org-A admin requesting an org-B eventId
gets 403, never a cross-tenant socket. Because the room is the data (no
firewall sits between a ticket and a Durable Object room), the compiler rejects
at build time a namespace-backed wsTicket whose scopeTable isn't
tenant-scoped (firewall: { exception: true } or a literal-only firewall with no
org/owner/team isolation). See
Realtime tickets.
v0.37.0 — May 26, 2026
The authz-as-compile-target roadmap ships. Your authorization model —
relationships, named permissions, FK-traversal arrows, scope axes — compiles
into one normalized policy IR, lowers to SQL WHERE clauses / signed claims /
guards, and is checked by a static analyzer before a single route is emitted.
This release lands milestones M0.1, M0.3, M1, M2, and M4, plus §5.3 namespace
scope-minting.
Named permissions (authz.permissions) — M1
A via: arm references one relationship. When the same grant rule recurs
across resources — "organizer or confirmed attendee" — name it once
under authz.permissions as a boolean expression and reference it everywhere
with a { permission } firewall arm.
authz: {
permissions: {
'event:view': { anyOf: ['organizerOf', 'attendeeOf'] },
'org:admin': { anyOf: [{ role: 'admin' }, { role: 'owner' }] },
},
}
// then on a resource:
// firewall: [ { field: 'eventId', permission: 'event:view' } ]anyOf lowers to an OR of relationship subqueries, allOf to an AND, and a
{ permissionRef } inlines another permission's arms — byte-identical to
writing the via: arms by hand. The full DSL (anyOf / allOf / not, bare
strings, and the object-leaf escape hatch) is accepted so configs stay
forward-compatible; a firewall enforces the SQL-pushable subset (claim-site
leaves like { role } / { scopeRole } and not over a SQL site are rejected
at compile time — gate those on access instead). See
Permissions.
FK-traversal arrows (authz.arrows) — M2
An arrow inherits authority across a foreign key: "you can see this row
because you administer the organization it belongs to", or "because you
control an ancestor in the tree." Declare an FK hop, reference it from a
permission as { arrowRef, permission }, and it lowers to SQL.
authz: {
arrows: {
eventOrg: { from: 'event', fk: 'organizationId', to: 'organization' },
folderTree: { from: 'folder', fk: 'parentId', to: 'folder', recursive: true },
},
permissions: {
'event:view': { anyOf: ['organizerOf', { arrowRef: 'eventOrg', permission: 'org:admin' }] },
'folder:read': { anyOf: [{ arrowRef: 'folderTree', permission: 'org:admin' }] },
},
}A single hop lowers to an FK-hop subquery; a self-referential hop
(folder.parentId -> folder) lowers to a bounded WITH RECURSIVE CTE
(default depth 8, override per-arrow with maxDepth or per-permission with
permissionMaxDepth). The seed and every walked row are re-constrained to
the caller's active-org claim, an absent claim fails closed (1 = 0), and the
identifiers are compile-time constants while the claim binds as a parameter —
so an arrow is never an injection surface. An unbounded: true arrow with no
claim path is a hard compile error. Valid on both D1 and Neon. See
Arrows.
Compile-time authz analyzer + diagnostics — M4
The compiler now projects your whole authorization model into a single policy
IR and runs static checks over it, reporting on the existing
[quickback:authz] console channel with stable, greppable codes:
AUTHZ_EMPTY_PERMISSION— a permission that can never be satisfied (e.g.allOf(a, not(a))).AUTHZ_SHADOWED_ARM— ananyOfarm subsumed by a broader sibling.AUTHZ_UNREACHABLE_ROLE/AUTHZ_UNREACHABLE_VIEW— dead scope config.AUTHZ_NOT_OVER_RESOURCE— anotover a SQL site (the null-trap).AUTHZ_PUSHDOWN_COST— aninforeport of each permission's subquery count and recursive-CTE depth.AUTHZ_CAPABILITY_CEILING— error — a resource grants ascope:<kind>:<role>read beyond itsgrantsprofile.
Checks are warn-by-default, compile-continues (so a new check never breaks
an existing build on upgrade); set QUICKBACK_AUTHZ_STRICT=true to promote
every warning to a hard error. The analyzer is compile-time only — a defect in
a check downgrades to a single warning in the default path and can never take
down an otherwise-valid compile. See
Diagnostics.
Aggregate-only views — M0.1
A view can return a rollup and nothing else — no data[], no per-row field
selection — via aggregateOnly: true, with an optional kMin k-anonymity floor
that suppresses the aggregate below a threshold. This is what lets an F&B vendor
read dietary counts for an event with zero attendee rows. See
Views.
Scope leftovers — M0.3
The relationship-scope surface gains three pieces:
- Namespace scope-claim refresh — a namespace gating
/event/:eventId/*on a single conferring relationship lane refreshesctx.scope.<kind>in memory for the request, so an attendee hitting an action under the prefix gets the scoped role stamped without a separate/scope/v1/enterround-trip. q.scope()autofill —q.scope('event')auto-stamps a column from the verified scope claim (ctx.scope.event.id) on insert, the same wayq.scope('organization')stamps the tenant column. Insert-only and null-safe; it adds no read-timeWHERE.- Set-valued sub-keys — a
subKeysentry ending in[](e.g.'shuttleId[]') projects every matched relationship row's value into the claim as astring[], and the matching firewall arm is rewritten to aninArrayrow-slice (a driver covering multiple buses). An absent claim lowers toinArray(col, [])— a clean deny.
See Scopes & capability grants.
Namespace scope-minting (mintScope) — §5.3
A namespace can now MINT a persisted scope JWT from its resolver. Set
mintScope: true and, on a successful relationship probe, the resolver
returns a scope token via set-auth-token — so a native client hitting
/event/:eventId/* carries the scoped role forward without calling
/scope/v1/enter.
authz: {
namespaces: {
eventScope: { prefix: '/event/:eventId', via: ['attendeeOf'], mintScope: true },
},
}The mint fires only when via: confers exactly one scope role
unambiguously (a single conferring relationship lane); a multi-lane,
bulk-grant, or asymmetric via: suppresses the mint (fail-closed — those
callers use /scope/v1/enter). The token reuses the shared mint helper (TTL
clamped to ≤180s, sibling scope kinds carried forward) and the mint is
best-effort — a mint failure never fails the request, since the in-memory
stamp already authorized it. Opt-in per namespace; defaults to false. See
Scopes — mintScope.
Gotcha: a namespace emits its resolver — and thus the refresh and the mint — only when at least one standalone action's
path:falls under the prefix. A namespace containing only record-based actions (which mount under their resource's CRUD routes, not the prefix) is inert. Add a standalone action likeeventHomeat/event/:eventId/home. See Scopes — the resolver gotcha.
v0.36.3 — May 25, 2026
Fix: /auth/v1/* handler forwards executionCtx so OTP emails actually send
The generated createAuth(env, cf?, executionCtx?) schedules the fire-and-forget
verification-OTP send through executionCtx.waitUntil(...) — Better Auth advises
not awaiting the send so its latency doesn't leak into auth response timing.
That only works when executionCtx is passed. The composed Cloudflare worker
index emitted a bare createAuth(c.env) at the app.all('/auth/v1/*')
handler, so on Cloudflare the unawaited SES/SNS send was cancelled the moment the
handler returned — send-verification-otp returned 200 but no email went out.
The handler now threads createAuth(c.env, c.req.raw.cf as any, c.executionCtx),
matching the action call-sites (record-based, standalone, core/helpers) and
the getSession call now passes the cf binding. (The earlier waitUntil fix had
only patched the legacy providers/runtime index emitter, not this composed one —
the two had drifted.) Recompile + redeploy to pick it up.
v0.36.2 — May 25, 2026
Two firewall fixes uncovered in a relationship-scoped (event-attendee) project: scoped reads stopped hiding rows behind a misclassified soft-delete column, and the list route stopped rejecting org-optional callers before the firewall ran.
Fix: a firewall isNull predicate on a non-deletedAt column no longer globalizes as soft-delete
The scoped db wrapper used by actions enforces soft-delete by injecting
isNull(<col>) into reads. It built its list of soft-delete columns by treating
any firewall { field, isNull: true } predicate as a project-wide soft-delete
marker — so an isNull predicate on a foreign-key column (e.g.
{ field: 'agendaItemId', isNull: true }) was broadcast to every table that
merely had an agendaItemId column, rewriting scoped reads to
agenda_item_id IS NULL. Real rows (non-null FK) became invisible, so an action
that read-before-write saw "no row," attempted an insert, and 500'd on the
(agenda_item_id, guest_id) unique constraint.
Only deletedAt — the column the compiler injects into every table — now enters
the project-global soft-delete list. A custom isNull predicate is still enforced
per-table by that table's firewall (route layer); it is simply no longer
broadcast across the whole schema. This mirrors the v0.28 fix that made custom
owner columns per-table rather than global.
Fix: list route no longer hard-requires an org when the firewall makes org optional
The generated CRUD list route emits an ORG_REQUIRED 400 (with a sysadmin
escape) before the firewall runs. It fired whenever an org scope appeared
anywhere in the firewall — including when org is just one arm of an any:
group. So after the relationship-scope migration made a firewall org-optional
(any: [ { organizationId = ctx.activeOrgId }, { …via relationship } ]), a
plain-JWT caller with no activeOrgId (e.g. an event attendee scoped by
relationship) got an instant 400 — the org-optional firewall that would have
scoped them correctly never even ran.
The gate now keys off org mandatoriness (firewallRequiresOrg): it fires
only when an org claim is genuinely required — a top-level (implicit-AND) or
all-nested org predicate. When org is one arm of an any: group, the list
route skips the 400 and lets the null-safe firewall scope the caller: the org
arm drops when activeOrgId is absent, sibling arms (relationship/owner) bind,
and anyGuarded denies (1 = 0) when every arm drops — so a no-org caller sees
exactly what the declared policy grants, never an unbounded cross-tenant read.
?organizationId= cross-tenant addressing and the sysadmin escape are unchanged,
and a plain org-scoped resource (no any: group) keeps the ORG_REQUIRED gate
byte-for-byte. Write paths (create / put / batch) still require an org so new
rows are always stamped with a tenant.
v0.36.1 — May 25, 2026
Relationship-derived scoped roles — request-scoped authorization minted from a verified relationship into a short-lived JWT.
authz.scopes — scoped roles via a relationship probe
A new authz.scopes block turns a declared relationship into a request-scoped
role. Key the scope by a request field and back each role with a relationship:
authz: {
relationships: {
attendeeOf: {
from: 'guests',
subject: { column: 'userId', equals: 'ctx.userId' },
resource: { column: 'eventId' },
where: { status: 'confirmed' },
},
},
scopes: {
event: {
requestField: 'eventId',
roles: { attendee: { via: 'attendeeOf' } }, // shorthand: attendee: 'attendeeOf'
},
},
}The compiler emits a /scope/v1/enter route that probes the relationship for the
caller ("is this user a confirmed guest of eventId?") and, on success, mints a
short-lived JWT carrying the verified scope. The auth middleware reads that claim
into ctx.scope.<kind> (e.g. ctx.scope.event).
Scoped roles then work everywhere roles do — read.access, view access,
masking.show, action access — as scope:<kind>:<role>, and the firewall can
scope rows by the verified id:
read: { access: { roles: ['admin', 'member', 'scope:event:attendee'] } },
masking: { email: { type: 'email', show: { roles: ['admin', 'scope:event:organizer'] } } },
firewall: { any: [
{ field: 'organizationId', equals: 'ctx.activeOrgId' },
{ field: 'eventId', equals: 'ctx.scope.event' },
] },So an attendee who "enters" an event reads that event's guest list — the eventId
firewall arm matches their verified scope — with no org-membership role, and
email stays masked unless they hold scope:event:organizer.
Scoped-role name sugar
Write the bare role name and the compiler lowers it to canonical
scope:<kind>:<role> when it resolves uniquely:
access: { roles: ['attendee'] } // → scope:event:attendeeReserved names (admin, member, owner), pseudo-roles, and anything declared
in authz.roles are never rewritten. A bare name that's ambiguous across scopes
is a compile error pointing you to the explicit scope:<kind>:<role> form.
v0.36.0 — May 25, 2026
Project-wide id generation now applies to every write path, action db is
typed on Cloudflare D1, and realtime rooms can be gated on a unique non-PK column.
id default moves to the column ($defaultFn)
The configured generateId strategy now lands on the id column itself —
id: text("id").primaryKey().$defaultFn(() => createId()) — not just inside the
auto-CRUD route handler. Previously a table with an explicit text("id").primaryKey()
kept a bare PK, so a hand-written action doing db.insert(...).values({…}) got no
id and failed at runtime with a NOT NULL 500 — invisible to tsc because the
scoped db was any. The injector (which already did this for tables with no
declared PK) now also appends the strategy's $defaultFn to an explicit id PK.
generateId: false (client-supplied ids) and serial/integer PKs are left
untouched; the rewrite is idempotent. $inferInsert now marks id optional.
Action db is typed on Cloudflare D1
The scoped db passed to custom actions is no longer any. On D1 projects the
per-feature define-action helper types it as a Drizzle client whose
.insert().values() makes the org/owner/team scope columns optional (the runtime
fills them from context), while every other column you declared stays required —
so tsc catches a forgotten column, typos, and wrong types, and query results
come back typed. Neon/Postgres keep db: any for now.
ctx.userId is also narrowed to string (not string | undefined) in actions
whose access requires authentication — so a trusted write like
db.insert(t).values({ ownerId: ctx.userId }) type-checks without a cast.
Actions reachable anonymously (a PUBLIC arm, including inside or/and) keep
ctx.userId optional.
access.resource gains an optional key
The per-room authorization gate (bindings.durableObjects[].access) always
matched the WebSocket roomId against the backing table's primary key —
eq(table.id, rowId). Rooms addressed by a different unique column had no way
to use it: a Yjs collaborative editor keyed by event_pages.yjsRoomId
(ns.idFromName(row.yjsRoomId)) would never match on id, so projects dropped
the access block and hand-rolled enforcement in onConnect — or, more often,
shipped no row-level check at all, leaving the room reachable by any
authenticated user who could guess a room id.
The long form now accepts key:
access: { resource: { feature: 'event-pages', table: 'eventPages', key: 'yjsRoomId' } }The gate emits eq(table.<key>, rowId) against the named column. key defaults
to 'id', so every existing pin is byte-for-byte unchanged, and it also works
inside each resources[kind].resource of the multi-resource form. The compiler
validates the column exists against the pinned table's schema at compile time.
The "multi-table feature is ambiguous" compile error now points at all three
escapes — the long-form pin, the key option for non-id rooms, and declaring
the table's firewall with a via: relationship so membership gating runs in the
gate with no hand-written onConnect. See
PartyServer rooms → per-room authorization.
v0.35.0 — May 25, 2026
New FGA pillar — OpenFGA / Google Zanzibar Relationship-Based Access Control (ReBAC), compiled into your Worker. Plus a public-sharing surface (type-bound public access + signed share links) built on it.
Relationship-based access control (authz.fga)
Role-based access and one-hop relationship joins can't express graph-shaped
permissions: an editor who is automatically a viewer, a document that
inherits viewers from its folder, a team#member group grant, or a per-record
assignment that doesn't require a global role. The new authz.fga block adds a
true ReBAC engine modeled on OpenFGA:
- Model — declare object types, relations, and userset rewrites in the
OpenFGA modeling DSL (
or/and/but not,[user:*]public access,[team#member]usersets,viewer from parenttuple-to-userset). The compiler parses and semantically validates it at compile time — dangling type or relation references fail the compile loudly. - Tuples — stored in a compiler-owned, organization-scoped
fga_tuplestable the compiler emits and migrates. A Check never reads another tenant's tuples. - Check engine — resolved entirely inside the Worker (no external auth service): direct tuples, type-bound public access, userset subjects, computed usersets, tuple-to-userset, request-scoped contextual tuples, with cycle detection and a depth cap.
ctx.fga—check/listObjects/expand/write/read, org-scoped, available in every action and handler.access: { fga: { relation, object } }— a new declarative access arm. The compiler fetches the record and emits an inline, fail-closedctx.fga.check('user:' + ctx.userId, relation, object)gate (objectis ajob:{id}template over the record's columns).- Collection-level FGA filtering — an
fgaarm onread.access(or a named view) now filters the collectionGET /, not justGET /:id. The compiler resolves the caller's authorized object ids via the engine'sListObjectsand injects aninArray(id, …)into the list query, so pagination,total, andhasMorestay correct. Supported shapes are bare{ fga },or:[{ roles }, { fga }], andand:[{ roles }, { fga }]; deeper nesting is a compile error. The authorized set is bounded byauthz.fga.listObjects.maxIds(default 1000) — past the cap the request fails with a hard 422 (FGA_LIST_TOO_LARGE) pointing toPOST /fga/v1/list-objectsfor paginated enumeration. - REST API — an OpenFGA-compatible surface at
/api/v1/fga/v1(check,list-objects,expand,read,write), tenant-scoped; writes gated byauthz.fga.writeRoles(default['admin']).
Public sharing
authz.fga.sharing turns the FGA engine into a public-sharing mechanism:
- Type-bound public access — a model relation listing
user:*becomes shareable-to-everyone by writing one tuple. - Signed share links —
POST /api/v1/fga/v1/sharemints an HMAC-signed, opaque token for one(object, relation)grant;GET /share/v1/:tokenis a public, unauthenticated route that verifies the token and returns a configured public projection of the object (it leaks nothing beyond that object + relation). - Share URL — both a dedicated route (
/share/v1/:token, always mounted) and an optional vanity base URL (share.<your-domain>, added to your runtime routes) are supported.
q-recruit showcase
The recruitment example now models organization → job → application/interview
with FGA: a recruiter is automatically a viewer and can_manage of a job,
applications inherit viewers from their job, and a job can be made publicly
shareable. New assign-recruiter and make-public actions demonstrate
ctx.fga.write, and jobs.update gates on { or: [{ roles }, { fga }] }.
v0.34.1 — May 24, 2026
Patch fixing a silent-skip bug in drizzle migration emission, plus a new CLI subcommand for inspecting and repairing migration-state drift.
Compiler now fails loud on drizzle migration state drift
When quickback/drizzle/<dialect>/meta/_journal.json fell out of sync
with the on-disk .sql files — typically because someone hand-rolled a
migration without a journal bump, or because a prior compile collided
with an existing tag — drizzle-kit's diff base became stale and it
silently skipped emission. The compile reported success, no new .sql
was written for newly defined tables, and the next wrangler deploy
shipped a Worker that 500'd on every request touching those tables.
The compiler now runs a precheck before any drizzle-kit invocation and refuses to compile when it detects any of:
.sqlfiles on disk with no matching entry in_journal.json- journal entries that reference missing
.sqlfiles - non-sequential
idxvalues in_journal.json - two
.sqlfiles sharing the sameidx - (for
auth/andfeatures/only) journal entries with no matching<idx>_snapshot.json— drizzle-kit needs the snapshot as a diff base
Snapshot/journal drift that the previous console.warn only logged
server-side is now propagated to the CLI and printed under
"Compilation warnings".
New quickback migrations doctor CLI command
Two modes:
quickback migrations doctor # diagnose
quickback migrations doctor --rebuild-journal # repairDiagnose walks quickback/drizzle/{auth,features,files,webhooks,audit}/,
prints a per-dir table (.sql files, journal entries, snapshots),
and flags every drift kind the compiler precheck enforces. Repair
rewrites _journal.json from on-disk .sql filenames in sorted order —
this is the recovery recipe users were applying by hand. Snapshot drift
on drizzle-kit dirs (auth/, features/) can only be repaired by
running drizzle-kit introspect against the live DB; the doctor prints
the exact command.
Upgrade path for projects in the broken state
Projects with existing drift will get a clear error message and an explicit fix command on the next compile against v0.34.1:
✖ Drizzle migration state is inconsistent — refusing to compile.
Causes:
- quickback/drizzle/features: 7 .sql file(s) have no matching journal entry: ...
Fixes:
- Run `quickback migrations doctor` to diagnose, then
`quickback migrations doctor --rebuild-journal` to repair.No action needed for projects whose journals are already in sync.
v0.34.0 — May 23, 2026
Customizable email-OTP templates and a dedicated admin-create-user hook
— closes a longstanding gap where projects on email.provider: 'aws-ses'
had to live with the generic "Your password reset code is ..." copy for
admin-bootstrapped accounts.
Per-type email-OTP template overrides
Drop a .ts file at quickback/email-templates/<name>.ts that
default-exports (input) => ({ subject, html, text }), then reference
it from the new plugins.emailOtp.templates map. Missing keys fall
through to the package defaults from @quickback-dev/better-auth-aws-ses
— this is purely additive; existing emailOtp: true projects emit the
same code they did before.
defineAuth("better-auth", {
plugins: {
emailOtp: {
templates: {
'forget-password': './email-templates/forget-password',
'welcome': './email-templates/welcome',
},
},
},
});export default function welcome({ otp, name, appName, appUrl }) {
const greeting = name ? `Hi ${name},` : 'Hi,';
return {
subject: `Welcome to ${appName} — set your password`,
html: `<h1>Welcome to ${appName}</h1><p>${greeting} use this code: <b>${otp}</b></p>`,
text: `${greeting}\n\nYour ${appName} code: ${otp}`,
};
}Recognized keys: 'sign-in' | 'email-verification' | 'forget-password' | 'welcome'. The first three map 1:1 to Better Auth's emailOTP type
enum.
Synthetic welcome routing — credential-row lookup
'welcome' has no native Better Auth OTP type. When configured, the
generated sendVerificationOTP callback queries the account table
for a credential-provider row for the recipient before rendering;
if the user has none (typical of an admin-plugin-created account
without a password), the send is rerouted through the welcome
template. Without a welcome template, those sends fall through to
forget-password — same behavior as before. DB-error path fails open
to the normal forget-password flow so a transient failure can't
deadlock the password-reset surface.
Provider scope: overrides are only honored on email.provider: 'aws-ses'. The Cloudflare Email path uses the helper's own template
pipeline and ignores the new config.
New recognized hook: after-admin-user-create.ts
A virtual event that wires into the same Better Auth
databaseHooks.user.create.after slot as the generic
after-user-create.ts, but the generator gates the call on
_baCtx?.context?.path matching /admin/create-user so it only fires
when the row came from the admin plugin's create-user endpoint. Use it
for first-time-user provisioning (welcome email, default org
assignment, audit-log writes) without inspecting the BA context by
hand. The generic after-user-create.ts still runs for every signup,
including admin-created ones; the two coexist in a single merged
after: handler.
quickback/hooks/
after-user-create.ts # every signup
after-admin-user-create.ts # admin-plugin /admin/create-user onlyFile transport mirrors existing slots
The new quickback/email-templates/ directory follows the same
pattern as quickback/plugins/, quickback/hooks/, and
quickback/lib/: the CLI walks the folder and uploads each .ts file
in a new projectEmailTemplates field on the compile request; the
better-auth provider stages referenced files into
src/email-templates/<name>.ts and emits default imports in
src/lib/auth.ts. Files not referenced by any template entry are
ignored — the CLI uploads the whole directory; the compiler picks only
what's needed. A relative path pointing at a file the CLI never
shipped fails compile fast with a "place the file at … and rerun"
hint.
See AWS SES Plugin → Per-Type Template Overrides and Account UI → Hooks.
v0.33.6 — May 22, 2026
Two compiler-side fixes for the queue-consumer / embedding surface and one
schema-emission bug that blocked generateId: 'cuid' projects.
Schema-emitted createId() now ships with a polyfill (no missing import)
When providers.database.config.generateId is set to 'cuid', the audit-
field injector emits an id column like
id: text('id').primaryKey().$defaultFn(() => createId()). Previously the
schema source referenced createId but no import landed, so tsc on the
generated project failed with Cannot find name 'createId'. Recurring
bug — every recompile re-emitted the broken file (the "do not edit"
banner doesn't protect from regeneration).
Fix: the injector now prepends a top-of-file polyfill backed by
crypto.randomUUID() — a Workers / Node 19+ / Bun global — instead of
trying to import @paralleldrive/cuid2. The package isn't bundled by
Quickback (per the hooks doc's "stick to
built-ins" guidance) and the schema-gen sandbox can't resolve project
node_modules anyway, so the polyfill is the only path that works
out-of-the-box.
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
// Polyfill — Quickback compiler does not ship @paralleldrive/cuid2 in
// generated projects, and the schema-gen sandbox can't see node_modules.
// crypto.randomUUID() is a Workers / Node 19+ / Bun global.
const createId = () => crypto.randomUUID();
export const widgets = sqliteTable('widgets', {
id: text('id').primaryKey().$defaultFn(() => createId()),
// ...
});If you want real cuid2 strings (collision-resistant, shorter than UUIDs)
you can still install @paralleldrive/cuid2 and add the import yourself —
the injector honours an existing import and skips the polyfill.
queue-consumer.ts header reflects what the file actually does
Before v0.33.6 the generated src/queue-consumer.ts always carried the
preamble:
* Queue Consumer for Embedding Jobs & Custom Queue Handlers
*
* Processes embedding jobs asynchronously using Workers AI and Vectorize.
* Also supports custom queue handlers defined in services/queues/.— even on projects with zero embedding features. Users on
custom-queue-only deployments would search for EMBEDDINGS_QUEUE /
Vectorize bindings that wrangler.toml correctly didn't emit, wasting
diagnostic time.
The preamble now reflects the actual surface:
hasEmbeddings | hasCustomHandlers | Header |
|---|---|---|
| ✓ | ✓ | "Queue Consumer for Embedding Jobs & Custom Queue Handlers" |
| ✓ | — | "Queue Consumer for Embedding Jobs" |
| — | ✓ | "Queue Consumer for Custom Queue Handlers" + explicit "No embedding bindings (EMBEDDINGS_QUEUE / Vectorize / AI) are referenced" |
| — | — | "Queue Consumer Stub" (additionalQueues with no handler yet) |
Symmetric embedding detection prevents silent drift
The wrangler.toml emit path (EMBEDDINGS_QUEUE producer/consumer
declaration) and the queue-consumer.ts emit path (embedding handler code)
previously used two different functions to detect "this project has
embeddings": hasEmbeddingsConfig checked both feature.resource. embeddings and feature.tables[].resource.embeddings;
extractEmbeddingFeatures only iterated the tables array. A feature
shape with feature-level embeddings sugar but no populated tables would
land in the wrangler binding while NOT landing in the consumer file
— silent drift.
hasEmbeddingsConfig is now defined as
extractEmbeddingFeatures(...).length > 0. The two stay in lock-step
by construction. extractEmbeddingFeatures now reads from both shapes
(tables-level when present, feature-level otherwise).
v0.33.5 — May 22, 2026
Two structural fixes that resolve recurring classes of bug for projects with integer primary keys and for projects whose audit columns drift between schema and database.
URL :id params coerce to Number when the PK column is integer
Per-id CRUD handlers (GET /:id, PATCH /:id, DELETE /:id, PUT /:id)
extracted the URL param as a string and passed it straight into
eq(table.id, id). For resources with integer().primaryKey() columns —
triggered either by generateId: 'serial' on the database config or by
imported legacy schemas — every per-id route raised a downstream tsc
type error (integer ≠ string). One production project reported 249 such
errors across 30+ generated *.routes.ts files.
Generated handlers for integer-PK resources now emit:
const __idRaw = c.req.param('id');
const id = Number(__idRaw);
if (!Number.isInteger(id)) {
return c.json(ValidationErrors.invalidInput([{
path: ['id'],
message: `Expected integer, got "${__idRaw}"`,
code: 'invalid_type',
}]), 400);
}Text-PK resources (the default and the recommended path) are unchanged.
A soft-deprecation warning now fires at compile time when
providers.database.config.generateId === 'serial' is set, nudging new
projects toward text-PK strategies ('cuid', 'uuid', 'prefixed', or
the default). Existing serial-PK projects keep working.
Audit-field injection now runs for every dialect
The compiler auto-injects created_at, modified_at, created_by, and
modified_by columns into every feature table, and the scoped-db
runtime auto-fills them on insert. Until v0.33.5, the early injection
step in parseCompilerInput was gated on targetDialect === 'postgresql'. SQLite/D1 projects fell back to a later injection inside
generateFeature, which only fired when feature.tables.length > 0 —
features arriving without a populated tables array (raw-drizzle shapes,
certain legacy migration tables) silently skipped audit injection.
Symptom: schemas claimed the columns existed (so the scoped-db proxy
tried to fill them on insert), but the database table didn't have them.
Inserts failed with "column doesn't exist." Users reported this for the
user_preferences table and, earlier, for chat_reads (the
0028_chat_reads_id_column.sql incident).
The early injection now runs for all dialects. injectAuditFields is
already idempotent, so the subsequent per-target injections in
compilePureSQLTarget and feature-generator.ts are no-ops when the
columns are already present. Net: every feature's schema source has
audit columns by the time drizzle-kit reads it, regardless of dialect
or feature shape.
Upgrade path: recompile against v0.33.5, commit the generated migration
that adds the missing audit columns, then wrangler d1 migrations apply.
v0.33.4 — May 21, 2026
Patch fixes covering upgrade-path bugs and OpenAPI spec/route divergence. No new features or breaking changes.
OpenAPI emits CRUD endpoints only when the route emitter registers them
The OpenAPI emitter previously advertised GET /, POST /, GET /:id,
PATCH /:id, DELETE /:id, and the corresponding /batch operations on
every resource, regardless of which CRUD verbs were actually declared.
Clients generated from openapi.json then issued requests that fell
through to ROUTE_NOT_FOUND from the firewall layer — a silent contract
break for anyone whose code path was driven by openapi-typescript or
similar codegen.
Each gate now mirrors the route emitter exactly. A resource that only
declares read + create + delete (no update) gets no PATCH operations
in the spec. A write-only sink (create only, no read) gets no GET
operations. Same for batch variants — POST /batch only when
crud.create is set, DELETE /batch only when crud.delete is set,
etc.
If your generated client was calling these spurious endpoints, those calls were already failing at runtime; this just removes them from the spec.
Legacy D1 audit-timestamp migrations rewritten in place
Compilers ≤ v0.33.1 emitted
DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) for audit columns
(created_at, modified_at, etc.). Cloudflare D1 rejects this in
ALTER TABLE ADD COLUMN with SQLITE_ERROR [code: 7500] "Cannot add a column with non-constant default". v0.33.2 stopped emitting the
non-constant form, but pending unapplied legacy migration files on
upgrading projects still poisoned wrangler d1 migrations apply and
left the deploy stuck.
The D1 migration safety pass now retrofits these files in place,
replacing the strftime expression with the same '1970-01-01T00:00:00.000Z'
constant v0.33.2+ uses. The compile-time warning lists the affected
columns:
⚠ D1 helper adjusted features migrations for Cloudflare D1 safety:
0004_early_dracula.sql [rewrote=applied_at,created_at,modified_at]Upgrade path: recompile against v0.33.4, commit the rewritten SQL, then
wrangler d1 migrations apply proceeds.
D1 helper warning is now actionable
The same migration safety pass auto-prepends PRAGMA defer_foreign_keys = on to every drizzle table-rebuild migration — required on D1
because it doesn't honour PRAGMA foreign_keys=OFF/ON mid-migration the
way SQLite does. The pass used to surface this insertion as a warning
on every recompile, training users to ignore the warning entirely.
The PRAGMA is still inserted (it has to be), but no longer fires the
warning. Stripped Better Auth cleanup, reordered child-first DROPs, and
the new rewrote=… annotation above still warn — those are cases where
a human should look.
Reference: account.appUrl / account.tenantPattern
v0.33's redesigned dashboard org-card uses two new optional fields on
the account config to deep-link into the tenant's own app:
// quickback.config.ts
account: {
// …existing fields
appUrl: 'https://app.example.com',
tenantPattern: '/{slug}',
}Both are typed in the config schema and baked into the Account SPA at
build time as VITE_QUICKBACK_APP_URL and VITE_TENANT_URL_PATTERN.
Without them, the dashboard falls back to a single-target card that
links to the account org-settings page (legacy v0.32 behaviour). Adding
them enables the two-zone layout with the external deep-link affordance.
(No code change in v0.33.4 — the fields shipped with v0.33.0 but weren't called out in the changelog.)
v0.33.0 — May 21, 2026
Dedicated hostnames for /auth/v1/* and /api/v1/* (Shape 1)
Two new optional overrides let you carve specific backend surfaces onto dedicated hostnames, served by the same Worker:
// quickback.config.ts
auth: { domain: 'auth.example.com' }, // /auth/v1/* lives here too
api: { domain: 'api.example.com' }, // /api/v1/* lives here tooOn the dedicated hostname, the hostname IS the namespace — /v1/* is a shortcut that the Worker-entry rewriter prepends with /auth or /api before route matching. So https://api.example.com/v1/users reaches the same handler as https://quickback.example.com/api/v1/users. Both forms work; the long form is also accepted on the dedicated host. Non-matching paths return a JSON 404 ("Not found on api.domain — this hostname serves /api/v1/* and the /v1/* shortcut only") — each hostname stays dedicated to its surface. /health and /openapi.json are explicit exceptions because monitoring/discovery agents expect them everywhere.
Both new domains:
- Get a
{ pattern, custom_domain: true }route inwrangler.toml - Join
trustedOrigins(CORS allowlist) - Join the cross-subdomain cookie inference (auto-enables
crossSubDomainCookieswhen ≥2 same-parent subdomains are configured) - Join CSP
connect-srcso SPAs can fetch from them
Dual-reach: all routes remain reachable at the unified backend host (see below) regardless of which dedicated hostnames are configured. The dedicated hostnames are aliases, never replacements.
quickback.{baseDomain} codified as the always-present unified backend
The compiler has always auto-inferred a quickback.{baseDomain} hostname when any subdomain is configured. v0.33 promotes this from inferred fallback to load-bearing contract:
- The unified backend hostname is GUARANTEED to exist when any custom subdomain (
account.domain,cms.domain,auth.domain,api.domain,apps.<n>.domain) is set. - Every compiler-emitted backend route (api, auth, storage, health, openapi, realtime, mcp, bundled SPAs, sysadmin endpoints) is reachable at the unified host, always.
- Per-surface domain overrides are additive (Shape 1) — they create more hostnames, never remove routes from the unified host.
This is forward-pointing: the future management layer can rely on a single discovery URL pattern (https://quickback.{baseDomain}/...) to find any backend regardless of how the operator split their surfaces onto subdomains.
Inference also extends to consider auth.domain and api.domain as domain sources, so a project configuring only one of them still derives quickback.{baseDomain} automatically. Explicit config.domain still wins over inference.
compiler.routes config block (scaffold — partial)
Optional path-prefix overrides for compiler-emitted system routes:
compiler: {
routes: {
storage: '/files', // default '/storage'
broadcast: '/realtime', // default '/broadcast/v1'
mcp: '/agent', // default '/mcp'
health: '/_health', // default '/health'
openapi: '/openapi.yaml', // default '/openapi.json'
},
}Status: the config is validated and reaches the reserved-prefix registry (so SPA pass-through and compile-time collision detection already honor overrides), but the per-section route emitters still hardcode the defaults. Setting a non-default value today will produce a misconfigured Worker — the registry knows the new path but the actual app.route('/storage/v1', …) mount uses the old one. Don't set these in production yet. Per-section emit wiring lands in a follow-up.
/api/v1/ and /auth/v1/ are NOT configurable here — those are deep contracts touched by feature emitters, OpenAPI generation, generated clients, and BA's own basePath. The Shape 1 hostname overrides above are the supported way to relocate them.
v0.32.0 — May 21, 2026
Single-origin path-routed architecture
A modern Quickback project now runs on one Cloudflare Worker behind one custom domain, with the bundled Account and CMS SPAs path-mounted at /account/ and /cms/, system routes at /api/, /auth/, /storage/, and the user's own SPAs anywhere else they want. No cross-subdomain cookies, zero CORS preflights, no separate api./account./cms. subdomains required:
// quickback.config.ts
apps: { www: { domain: 'app.example.com' } }, // user's root SPA
account: true, // bundled at /account/
cms: true, // bundled at /cms/
// /api/, /auth/, /storage/, /openapi.json, /health all live here tooThe bug that previously broke this shape: when a hostname-mounted root SPA was configured (apps.www.domain), its middleware swallowed every path that wasn't in a hardcoded list (/api/, /auth/, /admin/, /storage/, /health). Requests to /account/profile got remapped to /www/account/profile, 404'd, then SPA-fallback'd to the wrong SPA. v0.32 introduces a reserved-prefix registry (resolveReservedPrefixes in apps/compiler/src/generators/core/reserved-prefixes.ts) as the single source of truth for what every catchall middleware must pass through:
- System (always present):
/api/,/auth/,/storage/,/health,/openapi.json - Conditional (only when feature is on):
/account/(whenaccount: true),/cms/(whencms: true),/broadcast/v1/(realtime),/mcp(MCP server) - User-contributed: every
apps.<name>.pathfor path-mounted apps
Path-mounted apps that collide with a system or bundled-SPA reservation throw a compile-time error naming both owners. apps.api = { path: '/api' } errors clearly instead of silently hijacking feature routes at runtime.
Admin URL namespace migration: /admin/v1/* → /auth/v1/admin/*
Quickback's legacy admin tier emitted three things under /admin/v1/*:
- A role-admit middleware that admitted both
'admin'and'sysadmin'— contradicting the pseudo-role evaluator's strict-independence model (ADMINadmits'admin'only;SYSADMINadmits'sysadmin'only). GET /admin/v1/organizations— a thin proxy to Better Auth's own/auth/v1/organization/list.GET /admin/v1/sysadmin/organizations— cross-tenant org listing, sysadmin-only.
In v0.32, the first two are removed and the third renames to GET /auth/v1/admin/list-organizations. The new path sits under Better Auth's admin URL namespace (sibling of /auth/v1/admin/list-users, /auth/v1/admin/ban-user, etc.), follows BA's verb-noun convention, and keeps its sysadmin-only gate inside the handler — Quickback-emitted, not BA-mounted.
What this enables: /admin/* is no longer a reserved system prefix. Projects can mount their own admin SPA via apps.admin = { path: '/admin' } without colliding with anything.
What changes for callers:
- The bundled CMS SPA's sysadmin org switcher is updated in lockstep (no migration needed for projects using the SPA as-is).
- External callers of
/admin/v1/organizationsshould migrate to BA's/auth/v1/organization/list(same behavior — caller's memberships). - External callers of
/admin/v1/sysadmin/organizationsshould migrate to/auth/v1/admin/list-organizations. - Features that previously relied on the "admit both admin AND sysadmin" middleware should declare
access: { roles: ['ADMIN', 'SYSADMIN'] }explicitly on each route.
The ADMIN_ROUTE_FEATURES carve-out in the compiler is also removed — feature routes always mount at /api/v1/<feature>, and admin-only behavior is enforced by per-operation access: { roles: ['ADMIN'] } at the resource level.
Two compiler bug fixes
- Soft-delete index callback parameter bug. The auto-index injector emitted hardcoded
t.<col>references regardless of the existing extras callback's parameter name. When a user (or upstream transform) wrote(table3) => ({ … }), the appended composite-index entries referencedt.organizationId, t.deletedAt—tundefined inside the arrow scope,ReferenceErrorat module load. Fixed inapps/compiler/src/utils/auto-indexes.tsby capturing and reusing the callback's parameter name. - Reserved-word action filename collision. Action discovery walks
actions/*.tsand emitsimport <name> from './actions/<name>'. A user filename ofimport.ts,delete.ts,await.ts,export.ts, etc. producedimport import from './actions/import';— a SyntaxError on module load. Fixed inapps/compiler/src/codegen/discovered-actions.tsby aliasing reserved-word and invalid-identifier filenames to__action_<sanitized>bindings while preserving the public action key (route emitters use bracket notation, so reserved-word keys are fine in the lookup map).