Quickback Docs

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 behavior

Deleting 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.changed

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

KeyControlsDefault
versionFraming, strictness, the auth lane'v2'
routesThe 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 to data.
  • Outbound webhooks are signed Standard-Webhooks-only; the legacy X-Webhook-* headers stop being sent. v1 already sent both sets, so consumers can migrate to webhook-signature before upgrading and the flip becomes a no-op for them.
  • Unknown ?fields= names return 400 instead of being silently dropped.
  • The Quickback HMAC-JWT lane is off unless auth.jwt is 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/v1 override 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 a problemJson(...) call without its import, so the generated project failed tsc.
  • organization: { allowUserToCreateOrganization: … } in object form emitted the key twice in one object literal (TS1117). organizationHooks had 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):

BeforeAfter
Bundle (raw)23,610 KiB17,218 KiB−6,392 KiB (−27%)
Bundle (gzip)3,027 KiB2,762 KiB−266 KiB (−8.8%)
Startup CPU (median)258 ms232 ms−26 ms (−10%)
of which GC77 ms70 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 = true in 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. A publish: '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-d1binding 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:

  1. 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 with query: { roles: [...] }; drop or: 'owner' if the table has no owner column.
  2. Don't mask itcolumn: 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 activeOrgId straight off the session row; a session outlives membership, and a bucket with no readRoles had 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 orgId and role from 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 a PUT URL 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 spurious fkNotFound (96f764ed) in 0.58.1; a CI deploy-serialization fix in 0.58.2 (2bf00e9c). 0.58.3 was 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: true re-enqueues the committed root row for embedding regeneration on the same at-least-once EMBEDDINGS_QUEUE lane the plain routes use — after the tx commits, so a rolled-back changeset produces no orphan jobs. Fail-closed: requires the root's embeddings block; honors onInsert/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).
  • notify sends a device push via the web-push provider (compile error without push: { provider: 'web-push' }). The audience is a defineRealtimeDelivery profile resolved with the same disclosure gates as broadcast — masked columns, q.stamp proven 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 to scope:<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.0q.table column 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-proven SESSION parity for q.stamp (b11fabfe), an RLS serial-sequence sweep with an audit-schema latch and fga_tuples backstop (299c93ee), changeset trigger parity and define-action typing 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.

On this page

Unreleased — the standard API shapeBREAKING: contract.version is retiredBREAKING: CloudEvents type is reverse-DNS prefixedBREAKING: outbound webhooks are Standard Webhooks onlyBREAKING: include and fields[...] are reserved query paramsFixed: scoped projects without auth.jwt could not admit their own scope tokensCMS and Account consume CloudEvents frames as-isallowClientIds — optional client-supplied ids on createv0.65.x — August 16, 2026Passkeys no longer silently degrade to rpID: 'localhost'APP_URL — per-deploy override for account.appUrlAccount SPA keeps its /account basepath through login redirectsFix: Start deploys of R2 apps failed to resolve aws4fetchStart: seeded admin uses your Quickback emailv0.65.0 — August 16, 2026All Quickback auth plugins are now bundled — zero @quickback-dev/* npm dependenciesv0.64.0 — August 15, 2026contract.version defaults to v2 — and no longer moves your URLsWhat the v2 default changes on the wireAction-schema harvest no longer keys on contract versionFixesv0.63.0 — August 14, 2026Action input schemas now fail the compile instead of degradingcontract.version default flips to v2 in v0.64.0MCP tool grouping no longer assumes a v1 path segmentv0.62.0 — August 13, 2026Security: record: access arms on GET /:id were never evaluatedSecurity: revocation is per scope kind, not per tokenGenerated projects with in-worker Durable Objects can install againFixed: two configurations that emitted uncompilable projectsdefineEmail — inbound mail, provider-agnostic executev0.61.0 — August 12, 2026Security: JWT revocation now fails closed when KV is unreachableSecurity: revocation was off by one secondv0.60.4 — August 4, 2026Fix: retired-path cleanup was inert in v0.60.1–v0.60.3v0.60.1 — August 3, 2026Fix: moved files no longer leave orphans behindv0.60.0 — August 3, 2026Spec blobs move out of the Worker bundlev0.59.1 — July 29, 2026Fix: auth and features D1 bindings could collapse onto one namev0.59.0 — July 29, 2026BREAKING · Masking: sensitive columns now require an explicit decisionMigratingSecurity fix: live views and realtime subscribe now evaluate read.accessSecurity fix: a team-only firewall no longer opens every tenantSecurity fixes: managed file storagev0.58.1–v0.58.3 — July 28, 2026v0.58.0 — July 28, 2026?include=<ownsRelation> — the aggregate reader landsafterCommit.reindex + afterCommit.notify — the 06 fast-follows landv0.57.1 — July 28, 2026v0.57.0 — July 28, 2026Typed scoped db on every Neon mode + write-side audit forbidsHard-stamp unification — one audit-actor contractVisible audit columns — ...q.audit() / ...q.softDelete() + canon validatorv0.56.0–v0.56.11 — July 21–27, 2026v0.55.1 — July 21, 2026Feature areas — hierarchical authz grouping (_area.ts)Access rules: named gates (authz.rules) and tenant anchors (authz.tenants)v0.55.0 — July 17, 2026Generated projects ship an AGENTS.md