Changelog
Release notes and version history for the Quickback compiler, CLI, and platform.
The current release series. Older entries are archived by version range:
Unreleased — the standard API shape
BREAKING: contract.version is retired
There is one standard API shape, so there is nothing to select. Pinning
contract.version is now a compile error on either value.
Delete that one line and keep contract.routes if you have it. routes
is the surviving axis and is what decides whether your URLs read /api/v1 or
/api/v2 — removing it would move them.
contract: { version: "v2", routes: "v1" } // before
contract: { routes: "v1" } // after — same URLs, same behaviorDeleting version: "v2" changes no behavior: it selected what is now
simply the standard shape. Deleting version: "v1" is a real migration — see
the contract page for the
four behaviors that pin opted out of, all now unconditional.
BREAKING: CloudEvents type is reverse-DNS prefixed
Realtime postgres_changes and view_changes frames now carry a
dev.quickback. prefix on the CloudEvents type:
myapp.registrations.insert → dev.quickback.myapp.registrations.insert
myapp.view.agendaBoard.changed → dev.quickback.myapp.view.agendaBoard.changedqbframe is the supported discriminator — do not match on type. Its
shape is not a stable contract; qbframe ("broadcast" / "postgres_changes"
/ "view_changes") is. A client switching on type breaks here.
Author-supplied broadcast event names are unchanged. A named-invalidation
frame's type is still your wireName verbatim — that vocabulary is yours,
not ours to prefix.
/asyncapi.json documents the new type patterns, and its info.version is
now the event-document version rather than a contract version.
BREAKING: outbound webhooks are Standard Webhooks only
The legacy X-Webhook-Signature / X-Webhook-Event / X-Webhook-Delivery
headers and the Stripe-format signPayload / verifySignature pair are gone
from the generated project — including from the public webhooks module's
exports. Receivers must verify with webhook-signature. This is the one
change a receiver absorbs without any config change of its own, so tell
your endpoint partners.
BREAKING: include and fields[...] are reserved query params
?include= is always available now (where allowlisted), which makes include
a reserved parameter and any fields[-prefixed key reserved too. A column
literally named include is no longer filterable via ?include=…. Unknown
?fields= and fields[<fk>]= names return 400 instead of being dropped.
Fixed: scoped projects without auth.jwt could not admit their own scope tokens
Scope admission used to be emitted only inside the JWT credential lane, so a
project with authz.scopes and no auth.jwt minted scope tokens that no
generated code could accept — 401 on every presentation, with no diagnostic.
Live since v0.64.0, when that lane stopped being on by default.
Admission is now emitted whenever the project declares scopes, independent of
auth.jwt. No config change is needed — and declaring scopes does not
open the credential surface: no POST /token route, no identity fast-path,
no bearer fast-path unless you separately declare auth.jwt.
This covers sessionless scope tokens (ctx.mintScope). A token minted
from a session — POST /scope/v1/enter, or a namespace with
mintScope: true — carries user claims rather than the scope-only marker, so
without auth.jwt it still cannot be admitted: 200 from the mint, 401 on
presentation. The compiler now warns instead of failing silently. Declare
auth: { jwt: {} } if you use those routes.
CMS and Account consume CloudEvents frames as-is
The bundled SPAs no longer invert realtime frames back into a private
{ type, table, event } shape, and they no longer sniff /api/v2 to pick
the broadcast URL. They match on qbframe, read data / qbseq, and take
/broadcast/v1 or /broadcast/v2 from the injected runtime config — the
same prefixes the worker actually mounts.
allowClientIds — optional client-supplied ids on create
providers.database.config.allowClientIds: true lets POST / and
POST /batch carry an optional id, built for optimistic updates: the
client mints the id up front (crypto.randomUUID() under
generateId: 'uuid'), renders immediately, and the server inserts that id —
or mints one when it is omitted, exactly as before. The id is shape-locked at
the Zod boundary to the active generateId strategy's format (400 on
mismatch, 409 on collision), guards stay on (the id is compiler-managed and
exempt from createable), and OpenAPI <R>Create schemas plus the client
Insert types advertise it as optional. A no-op under generateId: false
(ids stay client-supplied and required) and 'serial' / numeric PKs.
See Client-supplied IDs.
v0.65.x — August 16, 2026
Passkeys no longer silently degrade to rpID: 'localhost'
The generated passkey plugin (and the SEALED reveal step-up, which must verify
against the same relying party) now bakes a compile-time fallback derived from
your configured domain — account.domain when set, else the unified host.
ACCOUNT_URL remains a runtime override, and its bare origin is now used
for WebAuthn even when the value carries a path (https://host/account), so
one variable serves both the email link base and the relying-party origin.
The rpID derivation also stops stripping two-label hostnames to a bare TLD
(example.com no longer became rpID com). localhost now applies only to
projects with no configured domain at all.
APP_URL — per-deploy override for account.appUrl
account.appUrl is baked at compile time into the Account SPA runtime config
and the "Go To App" email CTAs, which pointed preview/dev deploys at
production. Set APP_URL on the deploy target to override both at serve
time — same one-bundle-several-hostnames pattern as EXTRA_APP_HOSTS and
BETTER_AUTH_URL.
Account SPA keeps its /account basepath through login redirects
On path-mounted deployments, a post-login redirect could navigate to a bare
path (/dashboard instead of /account/dashboard) and fall through to a
tenant route. The callback sanitizer now re-applies the basepath to
same-origin paths, covering every sign-in flow (password, passkey, email OTP,
signup) and social-link callbacks.
Fix: Start deploys of R2 apps failed to resolve aws4fetch
Package-mode compiles (Start's Deploy to Cloudflare) bundle against the
compiler image's /deps/node_modules, not a generated npm install. R2
apps import aws4fetch from src/storage/presign.ts, but it was only
declared on the generated package.json — CLI deploys worked, Start died
with Could not resolve "aws4fetch". The compiler image now ships the same
pin the generator writes.
Start: seeded admin uses your Quickback email
A browser deploy writes the first sysadmin with the verified email on
your Quickback account, not a synthetic owner@<app>.invalid address.
Reset admin password still rotates the hash only — the broker never
UPDATEs that email afterwards. See Owner access.
v0.65.0 — August 16, 2026
All Quickback auth plugins are now bundled — zero @quickback-dev/* npm dependencies
The compiler now vendors the AWS SES, AWS SNS, combo-auth, and
upgrade-anonymous plugin sources into generated projects at
src/plugins/<name>/, completing what the subscriptions plugin started in
v0.64: no generated project installs a @quickback-dev/* package from npm.
The plugin code ships with the compiler that wires it, so the emitted callers
(getSESConfig, OTP callbacks, upgradeAnonymous() calls) can never skew from
the plugin version they were generated against — and upgrades arrive by
recompiling, not by npm update.
Recompile and the dependencies disappear from package.json; no config
changes needed. The @quickback-dev/better-auth-* npm packages are being
retired from the registry — the vendored source in your project is the
supported copy.
v0.64.0 — August 15, 2026
contract.version defaults to v2 — and no longer moves your URLs
Semantics and routes are now independent axes.
| Key | Controls | Default |
|---|---|---|
version | Framing, strictness, the auth lane | 'v2' |
routes | The URL prefix, nothing else | 'v1' |
A project with no contract block runs the v2 contract on /api/v1. You
inherit the flagship semantics; your clients keep calling the address they
already call.
Previously these were one knob: version: 'v2' moved /api, /auth, and
/broadcast to /v2 as a side effect of selecting v2 behavior. That coupling
made the better contract expensive to adopt for the wrong reason — a URL
migration nobody asked for.
Breaking — action required if you pinned version: 'v2' for the URLs.
Add routes: 'v2' to keep serving /api/v2:
contract: { version: "v2", routes: "v2" }Without it, a previously-v2 project moves back to /api/v1 on its next
compile. Semantics are unaffected either way.
What the v2 default changes on the wire
URLs do not move. These do:
- Realtime frames become CloudEvents 1.0 envelopes. The largest change — every
subscriber re-parses. Routing info moves to
type, payload todata. - Outbound webhooks are signed Standard-Webhooks-only; the legacy
X-Webhook-*headers stop being sent. v1 already sent both sets, so consumers can migrate towebhook-signaturebefore upgrading and the flip becomes a no-op for them. - Unknown
?fields=names return400instead of being silently dropped. - The Quickback HMAC-JWT lane is off unless
auth.jwtis declared. ?include=, aggregate changesets (owns), and delegated principals become available without a pin.
Pin contract: { version: "v1" } to opt out. A pre-v0.64 project that leaves
contract.version unset gets a one-time notice naming what changed — the
generated wrangler.toml header records the resolved version in a
# quickback:contract stamp, so new projects and projects that have already
compiled on v2 stay warning-free without restating the default.
Action-schema harvest no longer keys on contract version
assertCompleteActionInputSchemas fired whenever the contract was v2 and any
harvested schema was absent. With v2 as the default that would have failed
every Start project containing an action —
rawFiles callers post project source and never execute action code, so they
cannot harvest at all.
The gate now fires only on a partial payload: some schemas supplied, others missing. Callers that supply none get the coverage warning instead. The CLI has failed closed on its own since v0.63.0, with the local per-action reason the compiler cannot know.
Fixes
- CloudEvents framing was derived from
broadcastBase.endsWith('/v2'). Once routes and semantics decoupled, that silently reverted every default project to raw v1 frames. Framing is now passed explicitly. - The bundled CMS/Account SPAs gated their base-path override on semantics
when it is a routes concern, emitting a redundant
/api/v1override for every new project.
v0.63.0 — August 14, 2026
Action input schemas now fail the compile instead of degrading
Affects every project with actions, on every contract version.
The CLI harvests each action's input locally with z.toJSONSchema. When an
action failed to harvest, the compiler fell back to a static parser that reads
Zod as text — and that parser cannot emit required or additionalProperties.
The resulting operation kept its properties and looked fully typed in
openapi.json and the MCP tool list while enforcing nothing: generated clients
treated required fields as optional and unknown keys passed. Constructs the
parser could not read at all degraded further, to a bare {}.
None of it was visible in a diff. One project shipped ~346 unenforced request schemas this way; the change read as reformatting.
A failed harvest is now a compile error naming each action, whether bundling or
evaluation failed, and the underlying local error. This was previously the
behavior of contract.version: 'v2' only.
quickback compile --allow-degraded-schemas restores degrade-and-continue for a
single compile. It prints every action that fell back and why. (Removed in
0.66.2 — it worked on a total harvest failure but still threw on a partial one,
the case it existed for.)
The compiler also warns when a caller supplies no schemas at all — rawFiles
callers such as the Start app cannot harvest — naming how many actions of the
total lost required, with sampled paths.
contract.version default flips to v2 in v0.64.0
An unset contract.version resolves to 'v1' today. That default becomes
'v2' in v0.64.0. See the
contract matrix.
v0.64.0 shipped this without moving any URLs — semantics and routes became
independent axes, so /api/v1 stays put. This entry originally said the flip
would move /api/v1 → /api/v2; that is not what happened.
Every compile that leaves contract.version unset now warns. Pin
contract: { version: "v1" } to keep the current wire, or set "v2" to migrate
now.
Pinning your CLI version does not pin the contract. The default lives in the compiler, which is a hosted service, so an older CLI inherits the flip too. The config key is the only pin.
MCP tool grouping no longer assumes a v1 path segment
Internal. Group derivation matched the literal segment v1, so a contract-v2
path would have fallen through and grouped under api. Operation tags take
precedence and every generated operation carries one, so no shipped tool list
was affected — the fallback is now correct for any /api/v<n>/ prefix ahead of
the v0.64.0 default flip.
v0.62.0 — August 13, 2026
Security: record: access arms on GET /:id were never evaluated
Affects any resource whose read.access contains a record: arm.
A code rewrite corrupted "$ctx.userId" into "$effectiveCtx.userId" inside the
serialized access tree, and only the $ctx. prefix is recognised at runtime. The
arm therefore compared a column against the literal string and was permanently
false: a caller the config named was denied with 403 on the single-record read,
while the same tree admitted them everywhere else.
Those arms now evaluate. Callers your config grants access to will start
receiving 200 where they previously received 403. That is the documented
behavior being restored, not a widening — no caller is admitted whom the tree
does not name. Durable Object room gates already behaved correctly, so this also
removes a divergence where a room admitted a caller that GET /:id refused.
Security: revocation is per scope kind, not per token
Affects projects using authz.scopes.
A token carried one proof timestamp for a claim map whose kinds were proven at different moments, so re-proving one kind restamped the others. A revoked kind survived indefinitely as long as the caller kept re-proving any sibling.
Each entry now carries its own proof time, and both readers use it: the
revocation check, and the carry-forward cap. The cap matters more — it
applies on the default configuration, where revocationCheck is unset and no
KV marker is ever read, so it was the only bound on a carried claim.
Tokens minted before this release have no per-entry stamp and fall back to the
token-level proof time, conservatively. authz.scopes[].roles[].subKeys may no
longer declare id, roles, subjectId, or sctMs — those are server-minted,
and a row value overwriting one would forge a proof time. Compile fails naming
the path.
Generated projects with in-worker Durable Objects can install again
npm install failed with ERESOLVE immediately after a successful compile:
hono-party requires @cloudflare/workers-types@^4, and Quickback pins ^5.
Unsatisfiable at any version — no hono-party release accepts 5.x.
Generated package.json now carries a scoped override so the peer resolves to
the project's own pin. Nothing outside hono-party is relaxed. Only projects
declaring an in-worker Durable Object receive it.
Fixed: two configurations that emitted uncompilable projects
plugins: { apiKey: false }emitted aproblemJson(...)call without its import, so the generated project failedtsc.organization: { allowUserToCreateOrganization: … }in object form emitted the key twice in one object literal (TS1117).organizationHookshad the same collision and is now stripped from the merged options — the compiler owns it.
defineEmail — inbound mail, provider-agnostic execute
Declare an inbound handler in services/email/*.ts. The compiler emits a shared deliverInboundEmail() dispatcher plus a Cloudflare Worker email() adapter. Execute sees a portable InboundEmailMessage (from, to, headers, raw: ArrayBuffer) — the same body runs for Cloudflare Email Routing and Amazon SES (Receipt → S3 → SNS calling deliverInboundEmail). Optional to matches the envelope recipient. Unmatched messages log a warning and are not rejected. Audit actor is system:email-<name>. See Email.
v0.61.0 — August 12, 2026
Security: JWT revocation now fails closed when KV is unreachable
Affects projects using auth.jwt.revocationCheck: 'kv' — including every
project with a .sealed() column, which force-enables it.
Setting revocationCheck: 'kv' asks the verifier to consult current security
state before trusting a signed bearer. It did the opposite under failure: a
missing KV binding, a failed read, or a corrupt entry all returned "not
revoked," so a token was accepted during exactly the outage that hid its
revocation. A banned user's bearer kept working for the rest of its TTL, and
the one signal that would have caught it was the thing that had broken.
The check now returns active / revoked / unavailable, and only active
authenticates the bearer. A corrupt stamp is unavailable too — it used to
parse as NaN and compare as "not revoked."
What changes operationally. A bearer whose revocation state can't be read
gets a retryable 503 AUTH_REVOCATION_UNAVAILABLE (RFC 9457)
instead of being let through. Recovery is a separate cookie-only request: a
request carrying Authorization has its cookie stripped before session auth,
so during a KV outage every JWT-bearer request gets the 503 until the client
retries on its session cookie. One reason-only log line per occurrence carries
the cause (missing-binding / read-error) with no token, claim, or KV key in
it — alert on it.
This ties bearer-token availability to KV. That is the trade the opt-in now
makes honestly, and it is the right way round: a revocation check that gives
up under load is worth nothing. If you want the TTL bound without the
coupling, leave revocationCheck at 'none' and set expiresIn: 30.
Because the binding is now load-bearing, a project with named environments
must declare bindings.kv on every target — a compile error naming the
environment, rather than a 503 discovered after deploy.
revocationCheck: 'none' output is byte-identical. See
revocationCheck.
Security: revocation was off by one second
Also affects projects using auth.jwt.revocationCheck: 'kv'.
Revocation markers and token mint timestamps were both whole seconds, and the check rejected only a token stamped strictly before the marker. A token minted earlier in the same second as the revocation therefore compared equal and survived — a sub-second hole in a mechanism whose whole promise is "revoked within seconds." Whether an attacker landed in it was a coin flip on clock alignment, not on anything defensible.
Markers are now epoch milliseconds, tokens carry millisecond companions
(iatMs, and sctMs for the relationship-scope proof), and the comparison
denies a tie. A token minted milliseconds before the revocation is rejected; one
legitimately re-minted after it in that same second is still accepted, which
is why simply flipping < to <= on seconds was not the fix.
iat and sct remain standards-compatible unix seconds for external
tooling — the millisecond claims are internal ordering metadata, set by the
signer from the same clock sample and never accepted from a caller.
Rollout is conservative, not clever. Marker format is read from an explicit
version marker, never guessed from magnitude. Where one side is from v0.60.4 or
earlier — an old token against a new marker, or any token against an old marker
— there is no sub-second information to compare, so the entire marker second
is revoked. That over-revocation is bounded (old tokens expire within their TTL,
old markers within the entry lifetime), and it costs the caller a 401 and a
re-authentication, exactly as any other rejected token does. No action is needed
upgrading forward.
Do not straddle this release, though. A worker from before v0.61.0 reads a
millisecond marker as NaN and concludes not revoked — it does not recognise
it as corrupt, so the fail-closed path added above never fires. Rolling back
across this release, or running two differently-versioned deploy targets against
one shared KV namespace, means every revocation written by the new version is
silently unenforced by the old one until the entry expires (~19 minutes at
default settings). Upgrade targets that share a namespace together. If you must
roll back, treat outstanding bearers as unrevoked for that window: lower
expiresIn, or purge the qbrev: keys and re-stamp from the old version.
v0.60.4 — August 4, 2026
Fix: retired-path cleanup was inert in v0.60.1–v0.60.3
The stale-file cleanup shipped in v0.60.1 never ran. Both ends were correct —
the compiler resolved the retired paths, the CLI was ready to delete them — but
the v2 compile stream's done event didn't carry the field, so it arrived
undefined and the CLI dutifully deleted nothing.
The event is a hand-maintained projection of the compiler's output, and nothing type-checks that it stays complete: a field added on both sides but missed there is dropped in transit with no error anywhere. The only symptom was an absent side effect.
The underlying duplication is gone rather than patched: the event is now
derived from the compiler's output by omitting files, instead of three
separate hand-maintained lists (the server builder, the CLI's type, the CLI's
reconstruction) that all had to agree. A new field reaches the CLI for free. A
test still asserts the contract so the intent stays visible.
If you compiled on any of v0.60.1–v0.60.3, the stale root openapi.json /
asyncapi.json / schema-registry.json are still on disk. Recompile on
v0.60.4 and they are removed.
v0.60.1 — August 3, 2026
Fix: moved files no longer leave orphans behind
Upgrading to v0.60 left the old openapi.json, asyncapi.json, and
schema-registry.json sitting at your project root. The compiler wrote the new
copies under src/apps/__quickback/spec/, but nothing removed the old ones —
on a real project that was 6.7 MB of stale specs, still committed, that
tooling pointed at the old path kept reading in place of the live ones.
That is worse than a clean break: a stale spec drifts from your API silently, where a missing file fails immediately.
The root cause was structural, not a one-off. The compiler's output is a
manifest of files to write, with no way to express "and drop what you wrote
last time at a path I no longer use." The CLI's pre-write wipe covers src/
only, so any path that relocates outside src/ leaves an orphan forever.
The compiler now also returns retired paths, and the CLI deletes them on your next compile. See Retired paths.
Every filter errs toward keeping a file, because a wrong deletion is unrecoverable while a missed one is only untidy — a moved path is removed only once its replacement is actually emitted, a path the current compile still writes is never removed, and nothing may escape the output directory.
No action needed: recompile and the stale files go.
v0.60.0 — August 3, 2026
Spec blobs move out of the Worker bundle
openapi.json, asyncapi.json, and schema-registry.json are no longer
imported by the Worker. They are emitted into src/apps/__quickback/spec/ and
served through the Cloudflare ASSETS
binding.
Previously the specs shipped as text imports parsed lazily on first request. That kept their object graphs out of the isolate startup window, but the bytes still sat in the script V8 has to parse on every cold start. On a large API that is measured in megabytes, spent to serve discovery routes most callers never request. As assets they leave the bundle entirely.
Measured on a 40-feature production API (paired runs, alternating, same machine):
| Before | After | ||
|---|---|---|---|
| Bundle (raw) | 23,610 KiB | 17,218 KiB | −6,392 KiB (−27%) |
| Bundle (gzip) | 3,027 KiB | 2,762 KiB | −266 KiB (−8.8%) |
| Startup CPU (median) | 258 ms | 232 ms | −26 ms (−10%) |
| of which GC | 77 ms | 70 ms | −7 ms |
The bundle numbers are exact. The CPU figure is a local wrangler check startup median whose run-to-run ranges overlap, so treat −10% as the shape of
the win rather than a precise constant — and note it scales with spec size: a
project with a small spec will see little.
Two consequences worth knowing:
- Projects now get an
[assets]block even with no CMS, Account, or custom app configured. run_worker_first = truein that block is security-load-bearing, not a performance setting. It routes every request through your Worker first, so an asset is served only when the Worker fetches it — which is what keeps__quickback/spec/reachable exclusively through the gated spec routes. Apublish: 'admin'spec is not a public URL. Do not relax it.
The [[rules]] type = "Text" block added in v0.59 is gone; nothing imports the
specs now.
Routes and payloads are unchanged: GET /openapi.json returns the same spec
under the same openapi.publish gate. See Output
structure.
BREAKING — openapi.json is no longer written to the project root.
Tooling that read it (npx openapi-typescript openapi.json, CI steps, lint
rules) must point at src/apps/__quickback/spec/openapi.json.
api-types.gen.ts is unaffected — the compiler still generates it at the
project root, which is why most projects never invoke openapi-typescript
directly and need no change at all.
On your first compile after upgrading, the CLI deletes the now-orphaned root copies. It only does so for blobs it just re-emitted under the new path, so an older compiler that still writes to the root is never second-guessed.
v0.59.1 — July 29, 2026
Fix: auth and features D1 bindings could collapse onto one name
A project whose database config carried a single binding (e.g.
binding: 'DB') emitted two [[d1_databases]] entries bound to the same
name, while the generated code still called env.AUTH_DB. Half the runtime
therefore reached a database with no auth tables.
In split mode — the default for cloudflare-d1 — binding names the
features database only; auth is a separate D1 with its own authBinding.
The service resolver was letting auth fall back to the generic binding, so one
value drove both.
It went unnoticed because three places resolved the auth binding independently
and only one carried that extra fallback, so the reserved-name validator
computed AUTH_DB/DB, saw no clash, and passed while the wrangler emit
computed DB/DB. All three now agree.
A binding collision in split mode is also a compile error now rather than a
silently broken deploy, naming authBinding / featuresBinding and the
splitDatabases: false single-database alternative as the fixes.
Not introduced by v0.59.0 — the same behaviour is present in v0.58.3 and
earlier. If you hit it, set authBinding explicitly or upgrade.
v0.59.0 — July 29, 2026
BREAKING · Masking: sensitive columns now require an explicit decision
The compiler no longer decides whether to mask a column. It refuses to compile until you do.
Quickback has always detected sensitive column names (email, ssn, apiKey,
stripeSecret, refreshToken, …) and silently attached a default mask rule
plus a warning. That was wrong in both directions: it broke list views nobody
asked it to break, and it created a false sense of protection. Worse, only
some surfaces read the auto-detected set — through v0.58.3 the HTTP response
path applied declared-only masking while realtime and live views applied
detected-plus-declared, so an auto-detected secret was redacted over WebSocket
and returned in plaintext on GET /:id, list, POST and PATCH, and
stayed freely filterable and sortable. Making detection non-silent removes that
whole bug class by construction: the set of masked columns is now exactly the
set you declared, so no two surfaces can disagree about it.
Every detected column must now carry an explicit decision in masking:
masking: {
apiKey: { type: 'redact', show: { roles: ['admin'] } }, // yes, mask it
email: false, // no, reviewed
}false is a new first-class answer meaning reviewed — not sensitive here:
the value is returned in full and the column stays freely ?filter=-able,
?sort=-able and ?search=-able. An omitted key is not a decision.
Migrating
Compile the project. For each error, you get the resource name, every undecided
column, its suggested rule, and a paste-ready masking block with both answers
per column — delete the wrong line from each pair:
customers: 2 columns match Quickback's sensitive-column patterns but carry no masking decision.
...
masking: {
email: { type: 'email', show: { or: 'owner', roles: ['admin'] } }, // mask it
// email: false, // …or this instead: reviewed, not sensitive here
},Two ways to resolve each column:
- Mask it — keep the suggested rule (or write your own). The value is
redacted for callers outside
show.roles, and only those roles may filter/sort/search it. Widen the query surface independently withquery: { roles: [...] }; dropor: 'owner'if the table has no owner column. - Don't mask it —
column: false. Nothing changes about how the column behaves; you've recorded that somebody looked.
A false positive (cc on an accounting table, webhook on a config row) costs
one line. That is the intended trade: the compiler asks about everything it
recognises rather than guessing.
Detection itself is unchanged — same keyword table, same suffix matching, and
the suggested rule is still show: { or: 'owner', roles: ['admin'] }. It is
now a suggestion in an error message rather than a default that applies on its
own. See Masking.
Security fix: live views and realtime subscribe now evaluate read.access
The live-view materialize route (GET /api/v1/views/:view/:rootId) and the
view-subscribe ws-ticket lane applied the firewall and masking but never the
role gate. A firewall is a tenant filter, not a role gate, so a same-org
member who was correctly 403'd on GET /<table>/:id received the full row —
and every included child row — through the view, and could subscribe to its
live deltas.
Every table on a live view surface (the
root and each included child) is now gated by its own read: { access },
deny-on-any: a root denial is a 403, an included-table denial collapses to
404 with the firewall miss. Both entry points enforce it, and the gate lives
inside the materializer so resync inherits it too.
Shapes the view path cannot decide now fail closed at compile time instead
of silently passing: function-form access, relationship:/fga: arms, and a
record: condition on an included table. A record: condition on the root
is evaluated post-fetch against the materialized row, the same two-phase gate
GET /:id uses.
Security fix: a team-only firewall no longer opens every tenant
A resource whose only tenant predicate was team-scoped emitted no WHERE
filter at all when ctx.activeTeamId was absent, so every authenticated caller
could read, update and delete every tenant's rows.
The team predicate is rendered drop-when-absent — correct while an org arm still
bounds the query (the teamless rest-of-org slice), but with team as the sole arm
"drop the team filter" meant "drop the entire tenant boundary". The claim is
genuinely absent in ordinary setups: the Better Auth teams sub-plugin is opt-in,
and the external auth provider never supplies it. Auto-detection also derives
team-only isolation for any table carrying a teamId and no organizationId,
with no explicit firewall block.
An absent claim now denies when team is the sole tenant arm. Soft-delete and literal predicates are row filters, not tenant boundaries, so they don't count as a co-existing arm. Team alongside org is unchanged.
Paired with a compile-time gate: a team-only firewall on a project without the teams sub-plugin is now a build error rather than a resource that denies every request, since that claim can never be populated. See Firewall.
Security fixes: managed file storage
Three scope-expansion holes in the managed file subsystem (managed: true):
- Removed organization members kept reading private files. The files worker
read
activeOrgIdstraight off the session row; a session outlives membership, and a bucket with noreadRoleshad no second gate. Membership is now re-checked per request and a missing member row clears the organization before any org-scoped check. - JWT claims were trusted as current. The worker's JWT fast path took
orgIdandrolefrom the signed token — a mint-time snapshot. Both are now re-derived from the database. The lookup only runs when a request carries an org claim, so user-scoped and public reads stay lookup-free. writeScope: "user"buckets bound nothing to the owner. Keys come from a client-supplied path and the R2 write landed before the unique-key metadata insert could reject it, so a member who knew a peer's key overwrote their object — with no undo. Presigned uploads were worse: they signed aPUTURL and deleted the victim's metadata row. Both paths now check ownership first, and for presign that check runs before signing, since the URL is itself the write capability. The metadata row is also reserved before the R2 write, so a failed insert can no longer leave an overwrite behind.
See File storage.
v0.58.1–v0.58.3 — July 28, 2026
No changelog entries were written for these three patch releases. Reconstructed from git: compilation and generated-output hardening (
fc7d54fb) and a string-keyed batch FK visibility set that removes spuriousfkNotFound(96f764ed) in0.58.1; a CI deploy-serialization fix in0.58.2(2bf00e9c).0.58.3was published from CI and has no bump commit in this repository — its contents are not reconstructible from git history here.
v0.58.0 — July 28, 2026
?include=<ownsRelation> — the aggregate reader lands
read.include now accepts owns relation names alongside FK columns:
?include=reactions on a changeset root embeds the aggregate's owned child
rows (pivot on the root PK, match the child's declared fk), landing in the
same top-level included map keyed by include token, then row PK. The child's
full security surface applies exactly as for FK embeds — pre-record-evaluable
read.access (compile-enforced), firewall ANDed into the fetch on the
caller-claims handle, masking, and fields[<token>] sparse projection. The
?fields= pivot guard follows the direction: an owns embed requires the
source PK in the projection (an FK embed still requires the FK column).
This closes the changeset design's phase-2 reader — write the aggregate with
the changeset media type, read it back with ?include=.
afterCommit.reindex + afterCommit.notify — the 06 fast-follows land
The two deliveries deferred out of afterCommit v1 now ship, riding the same
post-commit dispatch seam (best-effort, waitUntil, never rolling back the
committed changeset):
reindex: truere-enqueues the committed root row for embedding regeneration on the same at-least-onceEMBEDDINGS_QUEUElane the plain routes use — after the tx commits, so a rolled-back changeset produces no orphan jobs. Fail-closed: requires the root'sembeddingsblock; honorsonInsert/onUpdate: false; a field-watch array means "always" on the aggregate lane. The owns validator's embeddings warning now clears for a reindex-declaring root (owned children still warn — root row only).notifysends a device push via the web-push provider (compile error withoutpush: { provider: 'web-push' }). The audience is adefineRealtimeDeliveryprofile resolved with the same disclosure gates asbroadcast— masked columns,q.stampproven identity, and child-id enumeration are shared code, not a parallel set. Title/body/url/ tag are{key}templates over a gate-checked payload map; an unmapped placeholder is a compile error, a null value at runtime pushes to nobody. Scope-keyed deliveries fan toscope:<room>subscription tags; user deliveries to the user's devices; roles AND-compose.
A reindex- or notify-only afterCommit no longer requires realtime
infrastructure — the ws-infra gate now applies only to broadcast/realtime
lanes.
v0.57.1 — July 28, 2026
No changelog entry was written for this release. Reconstructed from git: a polish pass over
0.57.0—q.tablecolumn initializers that cannot be resolved now throw instead of dropping silently (ace6cf17), the scope-grant gate distinguishes "permission arm present" from "no arm at all" (1b7a5773), tenant-scope arrow endpoints and object-thunk references parse again so authz analysis is restored (ad53b924), and the anchored fallback fails closed (48c70370).
v0.57.0 — July 28, 2026
Typed scoped db on every Neon mode + write-side audit forbids
The action db is now schema-aware on all Neon connection modes, not just
Hyperdrive: http mode gets the same insert relaxation and typed reads with
.transaction blocked at the type level (the neon-http driver throws at
runtime — the type now says so instead of a 500), websocket mode gets the full
interactive-tx flavor. With D1 already typed, every supported provider's action
db now carries the schema — including the SealedValue brand on .sealed()
columns, so string-processing a sealed value is a compile error everywhere.
On every typed provider, the compiler-managed audit keys are now ?: never in
.insert().values() and the newly-typed .update().set(): the audit
wrapper hard-stamps them from the verified caller, so a supplied value was
always discarded — now it's a compile error instead of a silent lie.
.set({ deletedAt }) stays allowed (the soft-delete signal).
Hard-stamp unification — one audit-actor contract
The scoped db's dead "fill-if-absent" actor lane is gone: on every wrapped
handle, createdBy/modifiedBy come from the verified principal, period (the
raw client remains the documented escape for system writes that own their
provenance). Three attribution gaps closed with it: cron schedules' and queue
handlers' withInternalContext now hand out an audit-wrapped db, so
system:cron-<name> / system:queue-<name> actually land on audit columns;
upserts into encrypted/sealed tables now stamp modifiedAt/modifiedBy on the
onConflictDoUpdate conflict branch like every other write; and an org-scoped
child table whose source can't accept the injected organizationId column now
fails the compile instead of silently shipping without its tenant column.
Visible audit columns — ...q.audit() / ...q.softDelete() + canon validator
The compiler-managed audit quartet and soft-delete pair can now be declared visibly
in authored feature files: q-DSL tables spread ...q.audit() / ...q.softDelete() into
the columns object (types flow through — $infer shows createdAt/modifiedBy/… on the
author's own table object), raw Drizzle tables declare the canonical lines literally.
quickback migrate visible-columns codemods an existing project in one shot; emitted
output is unchanged either way (the spreads expand to the exact injector lines).
With visibility comes validation, on by default: every feature table must declare its
managed columns visibly (...q.audit() / ...q.softDelete() or the canonical literal
lines — one quickback migrate visible-columns run), and every managed column that appears
in authored source is checked against the canonical spec — insert-time default chains on
createdAt/modifiedAt (+$onUpdate), nullable no-default actor columns, nullable
no-default deletedAt, canonical SQL names, never .encrypted()/.sealed(). Divergence
(silently accepted before) and missing declarations fail the build, with soft-delete
presence pinned to crud.delete.mode. Opt out with
compiler.features.visibleAuditColumns: false, which downgrades divergence to warnings and
restores invisible injection. Unknown spreads in a q-DSL columns object are now a compile
error instead of being silently dropped.
Also in this line: q.table's $insert type is honest (required vs defaulted/stamped
columns; audit keys are ?: never — the wrapper hard-stamps them), $infer includes the
audit columns, and the canonical column definitions collapsed into one shared spec
(constants/managed-columns.ts) consumed by the injectors, meta augmentation, request
validation, spread expansion, and the new validator.
v0.56.0–v0.56.11 — July 21–27, 2026
No changelog entries were written for the v0.56 line (twelve releases). Reconstructed from git: feature-area follow-through — generated CRUD/changeset routes claimed by an area prefix (
d33262e8), feature-area transition route adapters (05d57940), a fail-closed guard for generated routes under an area prefix (23f1f58d), and a run of cross-feature resolution fixes (b6984a22,747dfad4,fb0cfb41,26bc569f,ae76e3e8) — plus relationship-provenSESSIONparity forq.stamp(b11fabfe), an RLS serial-sequence sweep with an audit-schema latch andfga_tuplesbackstop (299c93ee), changeset trigger parity anddefine-actiontyping fixes (4c64edab), and R2 files-migration scheduling gated on managed files (29b908f2).
v0.55.1 — July 21, 2026
Feature areas — hierarchical authz grouping (_area.ts)
A folder under features/ carrying an _area.ts (export default defineArea({...})) is now
an area: it declares a route prefix + admission lanes (the same shape as a project
namespace, incl. mintScope/acceptScope) and an authz vocabulary — relationships, composed
roles, named gates, tenant anchors, scope kinds — that every feature inside the folder
references by bare name. The vocabulary is visible to the area's subtree ONLY: the compiler
checks every reference channel (access roles/gates/scoped aliases, action access/anchor,
firewall via/{ rule }/{ tenant } arms, ctx.scope.<kind> accessors, auth-view tenant
includes, realtime handshake roles, table-level namespace lanes, and other _area.ts nodes)
and fails the build on any outside reference. Root authz stays the global level, and root
bodies may reference root-declared names only (no laundering an area name project-wide).
Fail-closed rules shipped with it: re-declaring an inherited name is a compile error (no
overrides in v1); a descendant action whose path opts out of the area prefix must carry an
explicit route: 'self' marker; relative path: './…' resolves against the area prefix with
dot segments rejected; foreign features can't claim paths (or table-level namespace prefixes)
under an area's prefix; nested areas may add vocabulary but not mount a second prefix under a
mounted ancestor (unlinked parent/child admission never compiles); and area-tree projects
must declare requires: ['feature-areas'] — the marker reaches the compiler through ANY CLI
version, so an area-unaware toolchain is rejected instead of silently dropping nested
features. Area projects also emit a reviewed audit artifact, quickback/authz-manifest.json
(area tree, declared names, member features, area-gated vs self-gated actions, root-config
namespace consumers), and the generated AGENTS.md gains a feature-areas section.
Byte-parity guarantee: a flat root-authz project re-expressed as an area tree compiles to byte-identical output (the emit-site key-order determinism pass from v2 step 0 makes the fold order provably irrelevant). See Feature areas.
One cosmetic side effect of that determinism pass: the first recompile of an existing project on this release may reorder generated relationship-table import lines (they are now emitted in sorted order instead of authz-map insertion order). The reordering is a one-time, behavior-free diff.
Access rules: named gates (authz.rules) and tenant anchors (authz.tenants)
Two new root-authz blocks let you declare a row/role rule once and stamp it by name instead
of copying predicates across files. A named gate in authz.rules bundles up to three
arms — who (roles), record: (record-aware predicates), and firewall lanes — under one
name, referenced by bare name in any access: { roles: [...] } array (applies who + record)
and as { rule: '<name>' } in a resource's firewall: (splices the lanes into the top-level
AND array — a reference can only narrow row admission, never widen it). A tenant anchor
in authz.tenants names the tenant predicate ({ column, equals }, claim side locked to
verified ctx claims) and is written { tenant: 'org' } in firewalls — with an optional
per-site column override — and tenant: 'org' in auth-view includes. Expansion happens at
compile time; emitted SQL is byte-identical to the inline spelling.
Everything fails closed: gate/role/scoped-role/+/: name collisions, + hierarchy sugar
inside rule bodies, { rule } inside all/any groups, firewall-less gates referenced from
firewall:, firewall-bearing gates on actions or in masking.show, and an access-side
reference to a firewall-bearing gate whose lanes the resource doesn't also stamp are all
compile errors. Gates in realtime.requiredRoles lower to static who-roles at the handshake;
a declared non-empty list that lowers to nothing is a compile error, never a silent []. A
new AUTHZ_UNKNOWN_ROLE_NAME warning flags reference-shaped names that resolve to nothing.
See named gates ·
{ tenant } ·
{ rule }.
v0.55.0 — July 17, 2026
Generated projects ship an AGENTS.md
Every compiled project now emits an AGENTS.md at its root — the file coding agents read first — weaving Quickback's security model and a "when to use what" decision guide into the output. An agent editing the features is steered to the right primitive: a view for a filtered read, a transition for a guarded state change, an action for custom logic, guards/masking for field-level control, named invalidations for realtime refresh — each with a docs.quickback.dev link. It also documents the access tiers (incl. SESSION/SCOPED), the WHERE column = value firewall model with the org-OR-scope { any: [...] } pattern for non-org callers, and the fail-closed coherence guardrail (with the "never exception: true as a workaround" rule). Regenerated on every compile.