v0.40 – v0.48
Quickback release notes for v0.40.0 through v0.48.1 (June – July 2026).
v0.48.1 — July 10, 2026
Both entries below were authored under the working label v0.49 (commit
cd5bcd8f). No0.49.xwas ever published — the CLI went0.48.1→0.50.0— so0.48.1is the release that carries them.
Realtime identity tags — one send reaches a person on either auth door
WebSocket connections now carry identity tags, proven at ticket mint and
signed into the ws-ticket (never client-supplied): user:<userId> for every
Better Auth caller, plus scope:<kind>:<subjectId> whenever a scope subject is
proven — on either door. A sessionless scope principal's claim and a Better
Auth caller's matched relationship lane stamp the identical subject-keyed
tag (both derive from the same relationship row), so senders no longer
double-send per recipient to cover subject: 'both' roles.
targetTagson every send (realtime.broadcast,realtime.insert, …): delivery is the deduplicated union of the runtime's indexedgetWebSockets(tag)lookup — no per-frame attachment scan. Replaces theuserIdidentity arm when present; the scope/org bind andtargetRolesaudience still apply.- Fail-closed:
targetTags: []delivers to nobody, consistent with the empty-audience rule. - Tags are immutable for a socket's lifetime (hibernation-tag semantics); limits (10/socket, 256 chars) are validated at mint — rejected, never truncated.
Realtime authorization lease — hibernated sockets no longer outlive revocation
A ws-ticket was verified once at upgrade, and DO hibernation kept the socket
authorized forever — bans, member removal, and subject revocation never touched
a live connection. Connections now carry a delivery-time authorization
lease (default 15 minutes, from ticket iat):
- The fan-out skips expired connections (they receive nothing) and closes them with code 4401; inbound messages — even pings — are enforced the same way.
- In-place renewal:
{ type: "reauth", ticket: "<fresh ws-ticket>" }extends the lease and refreshes roles without socket churn. Renewal is strict — sameuserId, same scope/org, exact same tag set (tags are immutable); identity drift closes with 4401 → reconnect fresh. - Renewal tickets come from
POST /broadcast/v1/ws-ticket, which re-runs the full authorization gate (session, KV revocation, relationship/scope-claim probes) — so revocation propagates within one lease period, with no KV reads in the Durable Object. - Configure via
realtime: { lease: '15m' | seconds | false }(min 60s;falsedisables — discouraged). Clients that never reauth degrade gracefully to one forced reconnect per lease.
v0.48.0 — July 8, 2026
Realtime broadcasts are fail-closed — explicit per-resource audience (BREAKING)
Realtime broadcasting is now opt-in per resource with a mandatory audience, mirroring how CRUD is explicit. This closes a fanout footgun where a resource that never mentioned realtime could still broadcast its rows to an entire room.
realtime.access: { roles: [...] }is the audience — same{ roles }shape asread.access/crud.*.access. A subscriber receives a live row iff that role bucket would admit them at REST. Roles may be concrete membership/scope roles or UPPERCASE pseudo-roles (PUBLIC,AUTHENTICATED,USER,SYSADMIN).- Fail-closed, at compile time — a broadcasting resource with no/empty audience
is a compile error naming the resource. There is no implicit fanout. To
broadcast to everyone in the room, say so deliberately with
access: { roles: ['PUBLIC'] }. - Fail-closed, at runtime — the Broadcaster now treats an empty audience as
nobody (previously: everybody) and evaluates the uppercase pseudo-roles exactly
as the REST access evaluator does (
userRoleis threaded through the ws-ticket forUSER/SYSADMIN). - No project-wide auto-broadcast —
providers.database.config.realtimenow only wires the transport (Broadcaster DO + ws-ticket route); it no longer enables broadcasts on any resource. - Migration — a project that relied on the project-wide auto-broadcast must add
an explicit
realtime: { enabled: true, access: { roles: [...] } }block to each resource that should broadcast.requiredRolesis the deprecated spelling ofaccess.roles(still compiles, normalized internally).q-eventsguestsdemonstrates the staff-lane pattern (access: { roles: ['admin','member'] }, event-scoped, masked).
Deterministic codegen output
The compiler now sorts every filesystem-discovered collection (features, and each
services.* group) by a stable key before generating, so recompiles produce
byte-identical output regardless of the CLI's directory-scan order. Eliminates the
churny, behavior-neutral diffs (queue-consumer case order, auth blocks, schema
imports) that appeared across machines/CLI versions.
v0.47.14 — July 7, 2026
acceptScope: one namespaced handler for the user AND the sessionless principal
A project-level namespace can now accept an already-minted scope claim as
proof of entry — the inverse of mintScope. Set
authz.namespaces.<ns>.acceptScope: '<kind>' and the hoisted resolver gains
a fast-path: a scope-only principal (ctx.scopePrincipal, no ctx.userId)
carrying a verified ctx.scope.<kind> claim proves entry by that claim, without
a DB relationship probe. One /event/:eventId/* handler then serves both the
Better-Auth user and the sessionless invite-principal — retiring the parallel
PUBLIC /attendee/* twin.
- Named-string only — the kind is always explicit; a bare
trueis a type error. Default absent → the resolver never admits a scope principal (fail-closed). - Two locks, compile-enforced —
acceptScopeopts the namespace in, and at least one relationship lane must confer a<kind>role declaredsubject: 'supplied' \| 'both'. Asubject: 'user'role emits no accept arm; a kind whose every conferring lane is user-only is a compile error, not a dead fast-path. The kind must be declared and itsrequestFieldmust equal the prefix's first:param. - Forging-safe — the claim is verified (never caller-asserted), the kind is
read by name (not an any-key loop), the instance is pinned twice
(
entry.id === :paramin the gate +eq(loads.pk, :param)in hydration), and each accept arm is one exact(role, lane)pair. New proof source, zero new enforcement surface — firewall, masking,access, and scoped-db read the samectx.scopethey read for a userId caller. - Composes with
mintScope— a userId caller still gets a rolling re-mint; a scope principal's verified claim is preserved (not re-stamped) so itssubjectId/ sub-keys survive. - Realtime shares the same acceptance — the
ws-ticketroute now proves a scope principal through the same emitter as the REST gate (emitScopeAcceptance), so the two surfaces can't drift. Bringing the ws-ticket path onto it tightened it to the named-kind read + supplied-reachable role filter, closing an any-kind cross-kind match.q-eventsships avendorscope role (subject: 'both') + logistics-only view demonstrating the sessionless supplier end-to-end. - Writes work too — the scoped-db audit wrapper no longer throws when the
caller is a verified scope principal (
ctx.scopePrincipal, noctx.userId). Instead of failing on the missing user, it stamps the synthetic, audit-only principal id (scope:<kind>:<subjectId>, the token'ssub) intocreated_by/modified_by. So the SAME/event/:eventId/*handler body serves both a Better-Auth user and the sessionless principal on reads AND writes — no parallel raw-dbwrite path. Still fail-closed: a genuine no-user write (no scope principal) throws exactly as before.q-eventsadds avendorCheckinwrite under the acceptScope namespace exercising it end-to-end.
See Scopes → acceptScope.
v0.47.13 — July 6, 2026
Sessionless scope mint: ctx.mintScope + subject: 'supplied' \| 'both'
A scope can now be minted for a principal who has no Better Auth account — an event attendee arriving by email + invite code, a kiosk/handout code, a partner token. The action proves the relationship its own way and asks Quickback to mint a disciplined scope token from the row it proved.
authz.scopes.<kind>.roles.<role>.subject: 'user' \| 'supplied' \| 'both'(default'user').'both'makes the samescope:event:attendeereachable by a logged-in user (via/scope/v1/enter) and a sessionless invite-holder (viactx.mintScope) — one firewall arm, one masking rule, both principals.ctx.mintScope({ via, subject })— hydrated onctxin any action (including PUBLIC) when asupplied/bothrole is declared. The action supplies only the conferring row's primary key; Quickback reads the role, resource id, sub-keys, and subject id off that row — never caller-asserted, so the role can't be forged. Returns a scope-only JWT (no user identity):ctx.userIdstays undefined, so user/org/owner gates and theAUTHENTICATED/USERpseudo-roles all fail closed; onlyscope:<kind>:<role>gates admit it.ctx.scope.<kind>.subjectId— a reserved claim carrying the proven subject row's PK, populated on both proof paths. Scope an attendee's self-data (travel, dietary, todos) with a single firewall arm ({ field: 'guestId', equals: 'ctx.scope.event.subjectId' }) that serves both a user-attendee and a sessionless one.- Realtime — the ws-ticket authorizes a scope-only principal directly off its
proven
ctx.scopeclaim, so accountless attendees join the same rooms with the same scope role. Thewhere:filter runs on the mint probe, so a cancelled row confers nothing (revocation within one ≤180s mint cycle).
See Scopes → Sessionless scope mint.
MCP OAuth: split authorization-server / resource-server (multi-app SSO)
You can now run one canonical authorization server with many resource servers (each
hosting its own /mcp) over a shared auth DB:
- A resource server that sets
agents.mcp.requireAuthnow emits the full OAuth access-token verification path on its own — previously the JWKS-verify block only emitted when the separateauth.config.oauth.enabledflag was set, so a requireAuth-only resource server fell back to HMAC + session and 401'd every valid externally-issued bearer. - Point a resource server at an external AS with
auth.config.oauth.issuerUrl; the middleware verifies incoming bearers against the shared JWKS (iss/aud/exp+ live-session binding). auth.config.oauth.audiences(an array) registers federated resource-server audiences on the AS (RFC 8707). It now unions with the AS's own origin instead of replacing it, so listing federated servers never drops the AS's own/mcpaudience. Also exposed on the plugin-formoauthProvider.validAudiences.
See Agents → Split authorization server.
MCP tools: callable path params + typed input schemas
An action mounted under a path that carries a parameter — a namespace prefix like
/scope/:scopeId/..., or any path: with :params — now produces a fully
callable MCP tool:
- The mounted route's path params are extracted into the tool's
inputSchema(and_pathParams), so the model has a field to supply each one. Previously only record-based actions emitted a param (the hard-codedid); a standalone or namespace path param was absent from the schema, the URL resolved with an empty segment, and the action was uncallable over MCP. - Standalone routes are stored in Hono colon form (
:scopeId); the OpenAPI spec key (and therefore the tool's_path) is now normalized to brace form ({scopeId}) so the runtime's brace-only path interpolation can fill it.
Action input schemas also lower to richer JSON Schema instead of a phantom
empty {}: z.coerce.*, z.literal, unions of literals, and nested
z.object (child keys stay nested) now emit their real types, so the model —
and the OpenAPI spec — see the actual field shapes. See
Agents → MCP tools.
Scaffold surfaces MCP/OAuth + secrets opt-ins
quickback create now seeds the generated quickback.config.ts with curated,
commented-out hints for the authenticated MCP server (agents.mcp.requireAuth), the
split-AS oauth.issuerUrl/audiences, and bindings.secrets — so these opt-ins are
discoverable in the config itself, not only in the docs.
Custom secrets via bindings.secrets
Declare a custom secret your feature/action code reads off env — name and
contract only, never the value:
bindings: {
secrets: [
{ name: "WEBHOOK_SIGNING_SECRET", description: "HMAC for webhook callbacks", required: true },
],
}It's emitted into the generated env.d.ts Env interface so env.<NAME>
type-checks, and required secrets join the boot-time env guard — the worker
fails closed with 503 MISSING_ENV if one is unset. The value is set
out-of-band (wrangler secret put); the schema rejects any inline value so a
real secret can never be committed. See
Bindings → secrets.
Auth: resolve-email-context.ts hook for per-recipient OTP context
Drop quickback/hooks/resolve-email-context.ts and the emailOTP send calls it
per recipient, spreading the returned object into your email templates — branded
palettes (organizer vs. attendee), plan tier, locale, etc. — without patching
generated output. Fail-open, with the Drizzle handle passed in. See
Auth Hooks → Email context resolver.
Codegen fixes
- Schedules: handler import paths are now rebased on relocation, so a cron
handler importing
../../features/...resolves correctly from the generatedsrc/lib/schedules.tsinstead of failing the worker build. - OAuth/MCP middleware: the auth-schema tables are imported under aliased
names so a function-scoped
const sessioncan no longer shadow thesessiontable import — closes a temporal-dead-zone crash that silently 401'd every OAuth/MCP bearer. - MCP route: the large tool-data literal is split into a
mcp-tools.tsdata module (suppressed) so it can't overruntsc's type-instantiation budget; themcp.tsroute logic stays fully type-checked.
Realtime: identity-gated Durable Object rooms (roomIdEquals)
Per-user Durable Object entrypoints (gateway / presence / inbox) can now declare
an identity gate on the binding — access: { roomIdEquals: 'ctx.userId' } (or
'ctx.activeOrgId'). The compiler emits a Worker-edge gate at
/realtime/<kebab>/* that 401s anonymous callers, 400s a missing segment, and
403s unless the decoded room segment equals the server-resolved ctx id — a
string compare with no backing table or DB query. See
PartyServer rooms → Per-room authorization.
- Closes the cross-user hole that "no
accessdeclared" leaves open: a per-user room without a gate falls through to the authenticated-only catchall, so any authenticated user could open another user's room by guessing the id. - The gate lives at the edge, not in the DO — a Durable Object has no session
and only sees client-settable headers, so it must never re-authorize from
request data (trusting an
x-user-idheader was the original spoof hole). - Only
'ctx.userId'and'ctx.activeOrgId'are accepted; anything else is a compile-time error (fail-closed Zod + generator validation).
OAuth provider: spurious authorization-server warning silenced by default
The generated oauthProvider({...}) now emits
silenceWarnings: { oauthAuthServerConfig: true } by default. The compiler
always serves the /.well-known/oauth-authorization-server discovery route when
the provider is enabled, so @better-auth/oauth-provider's
"please ensure … exists" boot warning was always spurious. Override or extend it
via plugins.oauthProvider.silenceWarnings.
defineSchedule — cron jobs from a single source
Declare a cron schedule in services/schedules/*.ts and the compiler emits the
Worker's scheduled() handler + the matching [triggers] crons in
wrangler.toml — the trigger-side mirror of defineQueue. No separate cron
Worker, no hand-patching the generated entry (which a recompile would strip).
See Schedules.
// services/schedules/sweepDigests.ts
export default defineSchedule({
name: "sweepDigests",
cron: "* * * * *",
execute: async ({ db, env, services }) => { await runDigestSweep(db, env, services); },
});- One
scheduled(event, env, ctx)dispatcher runs every schedule whose cron matchesevent.cron; failures are isolated per-schedule. - The execute context (
{ db, env, services, cron, scheduledTime }) runs server-internal withctx.internal = trueand an audit actor ofsystem:cron-<name>, plus awithInternalContexthelper forroles: ['INTERNAL']-gated rows.
Rate limiting — the fifth security pillar
Per-operation request caps on every CRUD endpoint, backed by Cloudflare's
native Rate Limiting binding — no extra
storage, no third-party libraries. Applied to every resource by default
(read 1000/60s, write 200/60s), overridable per-op, per-resource, or
project-wide; false opts out at any scope.
- One middleware per feature router maps method→op, keys the limit by
<resource>:<op>:<userId>(falling back to the client IP for anonymous callers), and returns429+Retry-Afterwhen over the cap. - Unique
(limit, period)tuples collapse onto shared bindings — a project that never declaresrateLimitships exactly two (RL_1000_60,RL_200_60), emitted as GA[[ratelimits]]blocks inwrangler.tomland typedRateLimitonCloudflareBindings. periodis compile-time constrained to10or60(Cloudflare binding limit); the check no-ops when a binding is absent, so local dev never hard-depends on it.
v0.47.8 — June 12, 2026
Postgres-style table triggers (defineTable / feature triggers:)
Tables can now declare before/after triggers on insert/update/
delete, with two lowering lanes and a per-trigger compile report. See
Triggers for the full reference.
sql:entries compile to real SQLiteCREATE TRIGGERstatements, shipped in the journaled features migration (content-addressed — an unchanged trigger set appends nothing; removing a trigger drops it on the next migrate). They fire for every write path including raw SQL, run atomically with the triggering statement (the only transactional option on D1), supportwhen:guards andRAISE(ABORT, ...)rejection, andOLD./NEW.column references are validated at compile time. The compiler owns theBEGIN/ENDframe — emitted uppercase, since lowercasebeginbreaks remote D1's statement splitter. On soft-delete tables,*Deleteevents map to thedeleted_attransition (plus a hard-delete variant) and*Updateevents exclude it, so a soft delete fires delete triggers exactly once. D1 only.handler:entries run as application hooks at the audit-wrapper write chokepoint (REST CRUD + actions).before*hooks are synchronous and can mutate the payload or throw to reject — the write never executes and the request getsTRIGGER_REJECTED(422); audit fields are re-stamped after hooks and cannot be forged.after*hooks run once per returned row and are awaited — a throw surfaces asTRIGGER_AFTER_FAILED(500) withdetails.committed: truesince the write already persisted. Handlers get{ table, op, values|row, ctx, db, env }; thedbis audit-wrapped at cascade depth + 1, so hook writes stamp audit fields, emit live-view deltas, and fire nested triggers, bounded by a depth cap of 5. Each table's drizzle object is auto-imported into the generated registry under its authored name, and handler imports are usage-filtered and hoisted.
The compile output includes a lowering/coverage report and fail-closed
warnings: handler: triggers do not fire for queue handlers, cron, or
raw SQL — guards that must hold universally should use sql: +
RAISE(ABORT). async: true (queue-dispatched after-hooks) and
withOld: true (previous-row access) are reserved and rejected at compile
time until they ship.
Also in this change:
- New
triggererror layer withTRIGGER_REJECTED/TRIGGER_AFTER_FAILEDcodes in the generated error catalog, OpenAPI spec, and SDK types. - Parser: function-valued properties in
defineTable/featureconfigs now parse as compile-time source carriers instead of failing the whole config with "no dynamic expressions" — unblocks trigger handlers (and function-formaccess:/ custom masks) through the CLI source path. - Fixed a column-injection bug where a comment merely mentioning a column
name (e.g.
"deleted_at"in a docblock) suppressed audit/soft-delete column injection for the table. - Fixed a silent no-op: a project declaring
services/queues/*.tshandlers without embeddings oradditionalQueuesdeployed a wiredqueueexport with no[[queues.consumers]]to feed it — the handlers were dead code. The primary queue (and its typed env binding) is now emitted for handlers-only projects, with a compile notice that Cloudflare Queues requires the paid Workers plan. Queue emission remains strictly opt-in by usage: projects with no queue-backed features (embeddings, queue handlers,additionalQueues, webhooks) emit no queue configuration and deploy on the free plan.
v0.47.7 — June 12, 2026
Envelope encryption — q.text().encrypted() (encryption pillar, Phase 1)
Mark a text column .encrypted() and it is AES-256-GCM-encrypted at rest
under a per-organization key: a database breach (backup, export, leaked
token, SQL injection, insider read) yields only ciphertext for that column.
See Encryption.
- Per-org DEKs live wrapped under the
ENCRYPTION_KEKworker secret in a compiler-ownedorg_data_keystable (lazily created, versioned for rotation). Deleting an org's row crypto-shreds every value it wrote. - One write chokepoint: CRUD, batch, and action-layer
db.insert/updateall encrypt through the audit wrapper — audit snapshots, webhook payloads, and realtime deltas carry ciphertext by construction. - Reads decrypt behind the firewall then apply masking; encrypted columns
are compile-time excluded from
?search/?filter/?sort, view query allowlists, and embedding sources. ctx.crypto.encrypt/decryptfor explicit server-side processing in actions; boot guard refuses to serve without the KEK (503MISSING_ENV); scaffolded.dev.varsnow includes a generatedENCRYPTION_KEK.- The sealed (E2EE) and vault tiers remain compile-gated until built —
roadmap in
apps/compiler/research/encryption-vault-spec.md.
v0.47.0 — June 11, 2026
Breaking: zero-false-positive analyzer checks are now compile errors
The authz/authn analyzer (v0.37) shipped warn-only. After a release cycle of data, the checks whose every trip is a definite misconfiguration — never a legitimate config — are promoted to compile errors. Warnings nobody is forced to read are documentation; the pillar guarantees are now enforced.
Breaking — a config that previously compiled with one of these warnings now fails the compile:
AUTHZ_EMPTY_PERMISSION— a permission that is structurally unsatisfiable (allOf(a, not(a)): a fact AND its exact negation at the same AND-level).AUTHZ_CAPABILITY_CEILING— a resource's firewall grants ascope:<kind>:<role>role access beyond its capability profile (or the role has no profile at all). This is the M0.2 pillar guarantee — it was authored as an error all along and is now enforced.AUTHN_ROUTE_COVERAGE,AUTHN_SURFACE_INVARIANT,AUTHN_SECRET_CONSISTENCY,AUTHN_CREDENTIAL_DELIVERY,AUTHN_CLAIM_PROVENANCE— the structural-authn set (route auth coverage, bearer-only/mcp/agent surfaces + INTERNAL unreachability, mint↔verify key/issuer/audience pairing, anchored credential delivery, verifier-only identity claims). These still run against empty route/credential metadata on a real compile (no-ops until the generators emit it), but the severity contract is locked in now — they fail the build the moment they can fire.
The heuristic/advisory checks are unchanged: AUTHZ_SHADOWED_ARM,
AUTHZ_UNREACHABLE_ROLE, AUTHZ_UNREACHABLE_VIEW, and
AUTHZ_NOT_OVER_RESOURCE stay warnings; AUTHZ_PUSHDOWN_COST stays an info
report. QUICKBACK_AUTHZ_STRICT=true still promotes those warnings too.
The override escape hatch — every promoted finding can be explicitly accepted, per finding, with a required reason:
authz: {
analyzer: {
allow: [
{
check: 'AUTHZ_CAPABILITY_CEILING',
target: 'sessions/scope:event:organizer', // from the rendered finding
reason: 'organizer grant profile lands with the v2 rollout (TICKET-123)',
},
],
},
}An override downgrades that exact (check, target) finding back to a warning
that cites the reason — even under strict mode (it's an explicit, reasoned
acceptance). Every rendered error prints its exact override recipe. Guard
rails: check must be one of the promoted ids (unknown or keep-warn ids are
rejected at config validation), reason is required, and an allow entry
that matches no current finding is itself a compile error — no dead
overrides accumulating after the underlying issue is fixed.
Analyzer-internal defects are still fail-open: a bug in the analyzer itself downgrades to a single warning and the compile continues. Only real findings fail builds.
CLI: live views now compile through the CLI
defineView files under quickback/services/views/ were never loaded by
the CLI — the compile payload simply omitted them, so
live views (v0.35) only worked when driving
the compiler API directly. The CLI now discovers and ships them like every
other service definition, and the compile summary counts them
(Loaded services: … 1 live view(s)). If you declared views and wondered
why no surface ever went live: upgrade the CLI and recompile.
CLI: deterministic compile output across machines
Feature and service discovery used raw readdir order, which differs
between macOS and Linux — route registration order, schema re-exports,
OpenAPI paths, and the Neon RLS document's content hash could all churn when
the same project was recompiled on a different machine. All discovery sites
now sort by codepoint, so the same input compiles to the same bytes
everywhere. Expect a one-time diff on the first recompile if your previous
output was generated in a different filesystem order.
Fixes
- Read-only resources (no
crudblock) generated routes importing aCRUD_ACCESSsymbol their resource file never exported — a tsc/bundle break. The export is now unconditional. - The
blogstarter template gated writes on the removedADMINpseudo-role, which hard-fails current compiles; it now uses the org-membershipadminrole. reports/operations.manifest.jsonandreports/conformance.manifest.jsonnow also cover the realtime (/broadcast/v1), storage (/storage/v1), and FGA (/fga/v1) surfaces.- The dead free/pro tier plumbing is gone: no Tier row in
quickback whoami, no upsell copy at login. Compiling complete projects requires login, exactly as before — there was never a paid gate behind it. - The CLI is published to npm via trusted publishing (GitHub OIDC) — signed provenance, no long-lived npm token in the pipeline.
v0.46.0 — June 11, 2026
Breaking: "no organization context" responses unified on HTTP 403
Generated APIs used to answer the same semantic condition — the caller has no
active organization context — with two different statuses: 400 ORG_REQUIRED
from the collection list guard and the create/PUT/batch org validation, but
403 ACCESS_NO_ORG from standalone actions and 403 ACCESS_TENANT_SCOPE_REQUIRED from unsafe org-scoped actions. Clients and SDKs
had to special-case the split.
Breaking — recompile + redeploy required; every "no org context" denial is now 403:
GET /api/v1/<resource>(and views) without an active org or?organizationId=→403 ORG_REQUIRED(was 400).POST /,PUT /:id,POST /batch,PUT /batchwithout an active org →403 ORG_REQUIRED(was 400).- Standalone actions (
403 ACCESS_NO_ORG) and unsafe org-scoped actions (403 ACCESS_TENANT_SCOPE_REQUIRED) were already 403 and are unchanged.
Error codes are unchanged — ORG_REQUIRED, ACCESS_NO_ORG, and
ACCESS_TENANT_SCOPE_REQUIRED keep their identities, so clients that
discriminate on code need no changes. Only clients that branch on the raw
400 status for these responses must update to 403.
Breaking: root-mounted actions lose their nested alias URLs
A standalone action whose declared path: lives outside its feature prefix
(e.g. path: "/reports/orders" in the orders feature) mounts at the root:
/api/v1/reports/orders. That root URL is the one the OpenAPI spec, the MCP
surface, and the operations manifest have always advertised — but the
generated per-feature routes file also carried an undocumented, fully-gated
copy at the nested /api/v1/<feature><path> URL (e.g.
/api/v1/orders/reports/orders).
Breaking — the nested alias is no longer emitted. One URL per operation:
a root-mounted action exists only at its advertised /api/v1<path> mount.
Anything calling the unadvertised nested URL gets a 404 after recompile +
redeploy; switch to the documented root URL. Actions declared under the
feature prefix are untouched, and a feature whose standalone actions are all
root-mounted no longer emits an (empty) feature actions routes file.
Behavior change: PUBLIC inside or: arms admits anonymous callers
Access has always documented PUBLIC as
working on every access node, but the emitted auth gate only honored it in a
flat roles: [...] list. A tree like
access: {
or: [
{ roles: ["PUBLIC"] },
{ roles: ["admin"] },
],
}still answered anonymous callers with 401. The auth gate is now tree-aware
and matches the documented semantics: a PUBLIC or: arm admits anonymous
callers past the authentication gate (an and: group admits them only when
every arm does), and the access / org / firewall layers still evaluate the
full tree. One shared predicate now drives the emitted gates, the OpenAPI
security blocks, and view routes (which gate per view on the
view-effective access).
Worth a loud note: if you have a nested PUBLIC arm that was silently
401-ing, that endpoint becomes anonymously reachable after recompile —
exactly what the config declares, but verify it's what you meant. PUBLIC
audit logging applies to the newly-admitted routes as usual.
Realtime broadcasts now enforce requiredRoles and masking
A table's declared realtime gates weren't being applied to the generated
CRUD broadcasts: realtime: { requiredRoles: [...] } and column masking were
honored on the HTTP surface but not on the WebSocket fanout. The docs
promised per-role, masked realtime delivery; the generated broadcasts now
match it. Recompile and redeploy any project using realtime on
role-gated or masked tables.
Generated realtime.insert / update / delete calls now carry:
targetRoles— the table'srealtime.requiredRoles(post role- hierarchy+expansion), enforced per subscriber at fanout.maskingConfig— serialized from the same effective masking (declared + auto-detected sensitive columns) the HTTP mask functions are generated from. What HTTP masks is what the WebSocket wire masks.
Live-view view_changes deltas honor the same table-level requiredRoles
at fanout, so a role-gated table can't leak through a
live view subscription either.
Notes:
- Recompile suffices — no config change needed.
- If you worked around this with hand-written
realtime.*calls passingtargetRoles/maskingConfig, those workaround broadcasts can be removed. type: 'custom'mask functions can't ride the wire — the Broadcaster fails closed and broadcasts[REDACTED]for those columns.- Tables without
requiredRolesor masking are byte-identical: the open broadcast default is unchanged.
Batch operations inherit the base operation's access
When a batch operation was declared without its own access, it didn't pick
up the base operation's role requirement:
crud: {
update: { access: { roles: ["admin"] } },
batchUpdate: { maxBatchSize: 25 }, // ← no access of its own
}A batch config declared without access now inherits the base operation's
access at compile time — fail closed, so runtime, the OpenAPI spec, and the
generated code all agree. Explicit batch access still wins (including
per-side on upserts); a base op without access still yields an
authenticated-only batch op. The flat update: { batch: true } shape and
auto-promoted batch ops were never affected. Recompile and redeploy if
you use the explicit-batch shape without per-batch access.
Record actions, PUT upserts, and legacy firewall arms fail closed
Three fail-closed hardening fixes, found and verified against the new conformance catalog (route × principal expectations derived from the compiler's own IR — see the conformance manifest). All apply on recompile + redeploy; no config change needed:
- Record actions evaluate the role gate before fetching the record, so
the denial response no longer varies with record state — a uniform
403 ACCESS_ROLE_REQUIRED. (Bulk actions already had this order; unsafe actions write their audit row on the pre-record denial.) - PUT upsert treats an out-of-scope id as a firewall miss (
403reveal /404hide, per your firewall error mode; a standard 207 miss entry onPUT /batch) instead of routing it into the create path. Genuinely new ids keep the create path. - Legacy (array-form) firewall arms deny cleanly when a bound context
claim is absent at runtime (e.g. no active org), rather than surfacing a
database error — the same deny-when-absent behavior the explicit
all:/any:group forms already had.
Root-mounted standalone actions use the standard gate stack
Standalone actions mounted outside their feature prefix
(path: "/reports/orders") were emitted through a separate code path that
did not apply the full access / audit / scoped-database treatment that
in-feature standalone actions receive. They now render through the same
generator, so the same access checks, security-audit writes, and
tenant-scoped database wrapping apply everywhere. If you use root-mounted
standalone actions, recompile and redeploy. (Unifying the two code paths is
also what surfaced the two breaking changes above.)
Namespace routing fixes
- An action whose declared
pathexactly equals its namespace prefix is now claimed by the namespace like any nested action (Hono's/x/*mount already matched the bare/x) — it's served through the namespace's gate instead of carrying a second inline copy of the resolver. - A namespace with zero claimed standalone actions (e.g. record-based-only) no longer mounts its middleware at all — a dead gate can no longer intercept requests under its prefix.
Custom org roles work with Better Auth member management
Custom roles referenced in access trees (e.g. roles: ["support"]) are now
auto-registered with the Better Auth organization plugin. They were already
enforceable on routes but unprovisionable through Better Auth —
invite-member / update-member-role rejected them with
400 ROLE_NOT_FOUND, so there was no supported way to actually give a
member the role. Auto-registered roles carry member-equivalent permissions
(this is about assignability; your access trees remain the authorization
model). If you pass your own roles / ac to the organization plugin
options, those win and auto-registration is skipped. Built-in, pseudo, and
scope: roles are excluded; projects with no custom roles emit
byte-identical output.
snake_case tables: dropped record actions and broken helpers fixed
Two fixes for tables declared with snake_case names (event_notes):
- The generated
.quickback/define-action.tshelpers imported the camelCase identifier while the generated alias file exports the verbatim declared name (export { event_notes }) — a TS2724 compile failure for any snake_case table with action files. The helpers now import the real export aliased to the camelCase local. - Record actions on a snake-declared table living in a kebab-named feature
file (
event-notes/actions/pin.ts) were not emitted — missing from both the generated routes and OpenAPI. They now emit and document consistently.
If you have snake_case tables with record actions, recompiling adds routes that previously didn't exist — review the new surface.
First-class MCP OAuth config: auth.oauth
The OAuth 2.1 surface behind agents.mcp.requireAuth (v0.42.0) gains a
first-class config block — env-resolvable issuer / login / consent URLs,
scopes, RFC 8707 audiences, and a dynamic-client-registration toggle:
auth: defineAuth("better-auth", {
oauth: {
enabled: true,
issuerUrl: { env: "OAUTH_ISSUER_URL", fallback: "http://localhost:8787/auth/v1" },
loginPage: { env: "OAUTH_LOGIN_URL", fallback: "http://localhost:8787/account/login" },
consentPage: { env: "OAUTH_CONSENT_URL", fallback: "http://localhost:8787/account/consent" },
scopes: ["openid", "profile", "email", "offline_access"],
audiences: [{ env: "OAUTH_MCP_AUDIENCE", fallback: "http://localhost:8787/mcp" }],
allowDynamicClientRegistration: true,
},
}),Setting it auto-enables the oauthProvider, jwt, and bearer plugins,
emits the RFC 8414 / RFC 9728 discovery documents, and accepts the
provider's JWKS-signed access tokens in the standard auth middleware. A new
project hook, quickback/hooks/resolve-oauth-context.ts, lets you derive
tenant fields (activeOrgId / activeTeamId / roles) from the validated
OAuth identity. On Neon, the auth schema is now owned by the Better Auth CLI
end to end (the compiler no longer emits its placeholder src/auth/schema.ts
or src/lib/neon.ts). See
OAuth Provider (MCP-ready)
and Auth Hooks.
Neon RLS covers force-enabled plugin tables
On Neon, row-level-security policies are generated for the Better Auth
tables. The list of tables receiving policies was derived from your declared
auth config and didn't account for plugins the compiler force-enables
automatically — so some auth-owned tables could be created without RLS
policies. The list now derives from the fully resolved plugin set, so every
generated auth table receives appropriate policies (self-scoped where there
is a clear owner, service-role-only otherwise — fail closed). The RLS
migration is content-addressed, so the update arrives as a new journaled
entry on the next recompile + db:migrate. Neon projects should recompile
and run db:migrate.
New compile reports: operations + conformance manifests
Every compile now writes two machine-readable reports next to
openapi.json:
reports/operations.manifest.json— a stable, sorted index of every operation:{ operationId, method, path, auth, feature, kind }, covering the resource, action, view, auth, realtime, storage, and FGA surfaces. An extend-only public contract — the recommended integration point for scripting against the generated surface.reports/conformance.manifest.json— for each route × principal class, the expected outcome (allow / 401 / 403 / cross-tenant miss …), derived from the same IR the emitters consume. This is the artifact the conformance fixes above were found and verified against.
The OpenAPI generator itself now reads the same IR as the route emitters, so
the spec follows the emitted surface by construction — including four
accuracy fixes: PUT /{id} is documented only when actually enabled, the
batch surface documents the emitter's auto-promotion + access inheritance,
and views document their effective (view-level, falling back to read-level)
access. See
Operations manifest.
v0.45.0 — June 9, 2026
auth.jwt.revocationCheck: 'kv' — sub-TTL JWT revocation
The JWT fast-path trusts signed claims for their full lifetime — that's what
makes it a fast-path. Until now the only revocation bounds were the TTL
(plain claims) and the carry-forward cap (scope claims, v0.44.0). The
long-promised 'kv' mode is now real on the Cloudflare runtime:
auth: { jwt: { revocationCheck: 'kv' } }- Per-principal revocation timestamps, not per-token denylists. A
revocation event writes "everything for this principal minted before now is
dead" to the project's existing
KVbinding (keys prefixedqbrev:) — nothing extra to provision. Entries self-expire after the maximum token lifetime, so the store never accumulates. - The bearer fast-path checks before trusting. A verified token whose
iat(orsct, for the relationship-scope claim) predates the stored timestamp is rejected and falls through to session auth, which re-checks membership and re-mints — an active legitimate session recovers transparently. Costs 2+ edge-cached KV reads per bearer verify; the explicit trade the opt-in buys. - Written automatically where revocation actually happens: org-member
removal and role change (Better Auth
organizationHooks), and UPDATE/DELETE of scope-conferring relationship rows — through the same audit-wrapper write chokepoint Live Views uses, plus the single DELETE route (which has the row in hand but no.returning()). Revoke-on-touch: a false positive just forces a cheap re-prove. - User bans flip automatically. The Better Auth admin plugin's ban lands
as a user-row update, so a generated
databaseHooks.user.update.afterhook stamps the user-level revocation the momentbannedflips — every outstanding JWT dies on its next use, and the cookie/session path already rejects banned sessions, completing the lockout. When outbound webhooks are configured, the same hook emits auser.bannedevent so subscribed endpoints are notified. - App-code helpers for everything else:
src/lib/jwt-revocation.tsexportsrevokeUserTokens/revokeMemberTokens/revokeScopeTokens(e.g. callrevokeUserTokensfrom a custom revoke-all flow). - Fails open. A KV outage degrades revocation to the TTL / carry-forward-cap bound — it never locks every bearer out.
Bun/Node + 'kv' fails at compile time with a clear message (the store rides
the Cloudflare KV binding; those runtimes own immediate-revoke via the
cookie/session path). Default remains 'none'; without the flag the emitted
middleware is byte-identical to v0.44.0.
Neon: npm run db:migrate now applies the RLS layer
The RLS SQL (helper functions, policies, triggers) used to be emitted as
orphaned drizzle/migrations/0099–0102 files that no tool ever applied —
db:migrate ran the schema migrations and silently skipped the security
layer. The RLS document now rides the journaled drizzle set in
quickback/drizzle/ as a content-addressed migration
(<idx>_quickback_rls_<hash>), so one npm run db:migrate applies schema
and security, in dependency order. The migration is idempotent
(DROP POLICY IF EXISTS + re-create), so it converges over databases where
the old files were applied by hand; an unchanged security config appends
nothing (the journal stays byte-stable across recompiles), and a changed one
appends a new content-addressed entry — append-only, like every other
migration. Existing Neon projects: recompile + db:migrate, nothing breaks.
Neon: user_sessions is real, and the SQL layer speaks TEXT ids
get_active_org_id() — the helper every org-scoped RLS policy reads — was
querying a public.user_sessions table that no migration created, and
the helper layer was typed uuid against Better Auth's TEXT ids
(org_abc123), which would throw on first contact. Now:
user_sessionsis compiler-owned migration state (src/db/rls-support.schema.ts), created by the normal journaled flow with a FK to the users table — outsidesrc/auth/schema.ts, which the Better Auth CLI rewrites.- Generated Better Auth session hooks mirror the session's active organization (and team, in teams mode) into it on create/update. Switching to "no org" nulls it through — SQL-side access revokes, fail closed.
- The RLS helper layer (
get_active_org_id,get_active_team_id,is_owner, the audit helpers) is TEXT-typed to match Better Auth ids. - Policies for Better Auth's own tables use the plural physical names the BA CLI actually generates on Neon, and BA tables no longer get Quickback audit triggers for columns they don't have.
Hard deletes wire into the audit pipeline correctly
Two codegen bugs around delete: { mode: 'hard' }:
- Firewall auto-detection emitted an
isNull(deletedAt)predicate for tables with no soft-delete column — every read on a hard-delete table referenced a column that doesn't exist. - Hard-delete routes import the
logHardDeleteaudit helper, but the helper file was only emitted when the project also had unsafe orPUBLICactions — a project with hard deletes and neither failed at deploy time. Hard deletes are now a first-class trigger of the security-audit pipeline (helper file,AUDIT_DBbinding, env types, audit migration); on D1 that meansauditDatabaseIdis required, and the compile error says so.
Generated output is now typechecked — with the first crop of fixes
Every compiler change now compiles a matrix of fixture projects and runs
tsc + wrangler deploy --dry-run against the generated output — the
class of bug where the compiler emits syntactically valid TypeScript that
fails downstream. The first runs caught real ones, all fixed here:
- Neon:
src/db/org.schema.tsre-exported tables that don't exist after the Better Auth CLI's plural rewrite; a missingAuthDatabasetype export; transaction callbacks typed bare(tx)failingnoImplicitAny; the auth middleware's member-table fallback hardcoded the singular name. - Embeddings: the queue consumer's
Ai.runresult and the optionalEMBEDDINGS_QUEUEbinding both failed strict checks — the route now returns a structured503when the producer binding is missing instead of crashing. - snake_case relationship tables: relationship/arrow emitters imported
the camelCase identifier where the generated file exports the verbatim
table name (
export { event_guests }) — TS2724 for any snake_case relationship table. Imports now alias the real export (event_guests as eventGuests).
v0.44.0 — June 9, 2026
Scope carry-forward is now bounded (revocation fix)
The auth middleware's rolling refresh re-signs a verified scope claim on
every authed call without re-probing the relationship — that's what lets a
scoped session ride past the short JWT TTL with zero per-request DB cost. But
unbounded, it defeated the revocation model the docs promised: a removed
guest/staff who kept polling got a fresh TTL on every request, so "removal
takes effect within one TTL" only held for idle clients, and the refresh
also bypassed the ≤180s scope-token ceiling (it re-signed with the raw
auth.jwt.expiresIn).
Scope tokens now carry a proof timestamp (sct), stamped exclusively by the
proven mint sites (POST /scope/v1/enter and the namespace resolver, via the
shared mint helper). The rolling refresh:
- refuses once the claim's proof is older than the carry-forward cap
(15 minutes) — past it, the token expires within one TTL and the client
re-proves via
enteror any minting namespace route, which won't re-mint a revoked role. Worst-case post-revocation window for an active client: cap + one TTL from the last real proof, instead of unbounded. - carries
sctforward verbatim — the refresh can never restart the window without an actual relationship probe. Claims withoutsct(pre-0.44 tokens) fail closed and simply stop refreshing. - signs through the same ≤180s ceiling as the mint sites — an
auth.jwt.expiresInabove the ceiling no longer leaks into scope-bearing tokens via the refresh path.
No client changes needed: tokens renew exactly as before inside the window,
and re-entering is the existing flow. For tables where even the capped window
is too wide, gate with a via arm (live relationship subquery per request)
instead of a bare ctx.scope.<kind> arm; immediate revocation
(auth.jwt.revocationCheck: 'kv') remains on the roadmap.
v0.43.0 — June 4, 2026
Better Auth 1.6
Generated projects are now pinned to Better Auth 1.6. The agents.mcp.requireAuth work in 0.42.0 surfaced a long-standing version drift: the compiler emitted better-auth: ^1.5.0, but @better-auth/oauth-provider resolves a tree that requires @better-auth/core ^1.6.14 and imports @better-auth/core/utils/host + /redirect-uri — subpaths that only exist in 1.6.x. The result was a wrangler deploy that failed at bundle time with Could not resolve … and never uploaded.
This release aligns the entire Better Auth dependency set to a coherent 1.6.9 floor so npm resolves one consistent tree:
better-auth,@better-auth/drizzle-adapter,@better-auth/api-key,@better-auth/passkey,@better-auth/oauth-provider→^1.6.9(generated projects, the baked Docker deps, and the bundled CMS/Account SPAs).- Added an explicit
kysely ^0.28.16pin: Better Auth 1.6's migrator importsDEFAULT_MIGRATION_TABLE/DEFAULT_MIGRATION_LOCK_TABLEfrom kysely but doesn't declare kysely, so the Worker bundle needs it pinned directly.
No code migration was required — Quickback already uses @better-auth/oauth-provider (not the now-deprecated OIDC Provider plugin), and emits no freshAge/SAML usage affected by 1.6's behavioral changes. Verified end to end: a requireAuth project and a normal project both npm install a clean 1.6.14 tree and bundle with wrangler deploy --dry-run with zero resolution errors.
Larger migration histories no longer hit the upload ceiling
Long-lived projects accumulate one full-schema drizzle snapshot per migration, and the whole cumulative meta payload (snapshots + journals + .sql, across both the auth and features dirs) is re-sent on every compile — even a no-schema-change one. Projects with deep migration histories were hitting the compiler's existingFiles size limit and failing to compile, with no schema drift and nothing to fix on their end.
The server-side ceilings are raised so this is no longer the binding constraint:
existingFilespayload: 4 MiB → 16 MiB- total request body: 8 MiB → 32 MiB (kept above the per-category limits so it doesn't trip first)
This is enforced entirely on the compiler, so no CLI upgrade is needed — the new headroom applies as soon as compiler.quickback.dev is on this release.
Custom response headers (security.headers)
The generated Worker emitted a fixed set of security headers (CSP, HSTS,
X-Content-Type-Options, …) with no supported way to add your own — teams that
wanted, say, an X-Server-Version build stamp on every response had to
post-patch the generated middleware after each compile.
New security.headers sets arbitrary response headers natively. Values are
static strings or templates interpolating per-deploy Worker env vars via the
{{env.VAR_NAME}} token:
security: {
headers: {
// Both vars must be set, or the header is omitted entirely.
'X-Server-Version': '{{env.SERVER_VERSION}}+{{env.SERVER_BUILD}}',
'X-Powered-By': 'Quickback',
},
}- Omit-if-unset — a header that references env vars is emitted only when
every referenced var is set (non-empty) at runtime. A Worker that forgot to
inject the var emits no header, never
X-Server-Version: undefined. Static headers are always emitted. - The value is read from
c.envper request, so it picks up whatever Worker var is injected at deploy time — nothing is baked at compile time. - Managed headers are rejected — names owned by a dedicated
securityfield (CSP, HSTS,X-Frame-Options,Referrer-Policy, …) error at compile so the two emitters can't race. Malformed{{env…}}tokens error too, rather than leaking literal braces into the wire header.
Survives a clean quickback compile with no post-processing. See
Security Headers → Custom response headers.
v0.42.0 — June 4, 2026
Interactive OAuth for the generated MCP server
MCP connectors (Claude, Cursor, …) could only connect to a generated /mcp
endpoint anonymously — they never launched a sign-in flow, so auth-gated
tools stayed invisible. The OAuth machinery was already present (Better Auth's
oauth-provider ships /oauth2/authorize, /token, /register + PKCE under
/auth/v1); the compiler just never let a connector discover or trigger it.
New agents.mcp.requireAuth makes the MCP surface authenticated-only:
agents: { mcp: { requireAuth: true } },When enabled, the compiler:
- Challenges unauthenticated
/mcpwith401+WWW-Authenticate: Bearer resource_metadata="…"(RFC 9728 §5.1) — the signal connectors use to start OAuth. - Force-enables
oauthProvider+bearer, so/oauth2/authorize,/token, and/register(RFC 7591 dynamic client registration) exist with PKCE. - Delegates
/.well-known/oauth-authorization-serverto Better Auth's real metadata instead of a hand-rolled stub that advertised a non-existent authorization endpoint. - Adds the
<origin>/mcpresource tovalidAudiences(RFC 8707) so the connector's access token isn't rejected.
Login and consent are served by your Account SPA, so users authenticate with
whatever methods you already enabled. Default is unchanged (anonymous public
tools); set requireAuth: true for servers meant for authenticated users.
v0.40.2 — June 3, 2026
Passwordless plugins can no longer bypass the signup gate
Better Auth's emailOtp and magicLink plugins auto-create an account when a
code/link is verified for an unknown email — and that path is independent of
emailAndPassword.disableSignUp. As a result, account.auth.signup: false
closed POST /auth/v1/sign-up/email but left OTP / magic-link verification as an
open back-door for account creation.
The compiler now emits each plugin's own disableSignUp flag, and new
per-plugin options expose it directly:
auth: defineAuth("better-auth", {
emailAndPassword: { enabled: true },
plugins: {
emailOtp: { disableSignUp: true }, // OTP sign-in only; unknown emails rejected
magicLink: { disableSignUp: true }, // same for magic links
},
}),Both default to following the global gate: setting account.auth.signup: false
(→ emailAndPassword.disableSignUp: true) now also gates the OTP and magic-link
paths automatically. Set { disableSignUp: false } on a plugin explicitly to
keep its sign-up open while password sign-up stays closed.
v0.40.1 — June 2, 2026
- The generated
wrangler.tomlandEnvtype now emit theASSETSbinding type for projects that mount SPAs (CMS / Account), so handler code that referencesc.env.ASSETStypechecks. - Compiler Docker builds preserve their layer cache across rebuilds, cutting local recompile time.
v0.40.0 — June 2, 2026
R2 file storage is now presign-only by default. Adding
defineFileStorage("cloudflare-r2") emits just the ctx.storage signer — no
FILES_DB database, no /storage/v1/* endpoints, no files worker. You sign
presigned URLs against a bucket and store references in your own table. The
turnkey managed subsystem is still available, now behind managed: true.
This decouples the lightweight primitive from the heavyweight subsystem so you can add uploads without standing up a metadata database you don't use.
Presign-only by default; managed is opt-in
// Presign-only (default) — ctx.storage against a bucket, nothing else
defineFileStorage("cloudflare-r2", { bucketName: "my-app-media" })
// Managed subsystem — FILES_DB + /storage/v1/* + files worker
defineFileStorage("cloudflare-r2", { managed: true })- New projects can omit
bucketName— it defaults to<project>-media, so setup is onewrangler r2 bucket createplus the R2 API-token secrets. - Existing projects set
bucketNameto a bucket they already have. Presign-only emits no R2 bucket binding and no FILES_DB binding — nothing to provision, nothing to migrate.
Per-call bucket + custom secret names
// Sign against a different bucket than the configured default
await storage.signPutUrl(key, { bucket: "my-app-public", contentType });If your project already stores R2 credentials under different env-var names, map them so the signer reads yours:
defineFileStorage("cloudflare-r2", {
bucketName: "my-app-media",
presign: { accountIdEnv: "CF_ACCOUNT_ID", accessKeyIdEnv: "S3_KEY", secretAccessKeyEnv: "S3_SECRET" },
})Fixes
maxFileSizenow accepts a human string ("100mb","5gb") as well as a number of bytes — previously a string emitted invalid TypeScript.- Presign-only no longer emits a stray
FILES_DB[[d1_databases]]block (with an invaliddatabase_id = "local-files") intowrangler.toml, which would have madewrangler deployreject the worker.