# v0.40 – v0.48

## v0.48.1 — July 10, 2026

> Both entries below were authored under the working label **v0.49** (commit
> `cd5bcd8f`). No `0.49.x` was ever published — the CLI went `0.48.1` →
> `0.50.0` — so `0.48.1` is the release that carries them.

### Realtime identity tags — one send reaches a person on either auth door

WebSocket connections now carry **identity tags**, proven at ticket mint and
signed into the ws-ticket (never client-supplied): `user:<userId>` for every
Better Auth caller, plus `scope:<kind>:<subjectId>` whenever a scope subject is
proven — on **either** door. A sessionless scope principal's claim and a Better
Auth caller's matched relationship lane stamp the **identical** subject-keyed
tag (both derive from the same relationship row), so senders no longer
double-send per recipient to cover `subject: 'both'` roles.

- **`targetTags` on every send** (`realtime.broadcast`, `realtime.insert`, …):
  delivery is the deduplicated union of the runtime's indexed
  `getWebSockets(tag)` lookup — no per-frame attachment scan. Replaces the
  `userId` identity arm when present; the scope/org bind and `targetRoles`
  audience still apply.
- **Fail-closed:** `targetTags: []` delivers to nobody, consistent with the
  empty-audience rule.
- Tags are immutable for a socket's lifetime (hibernation-tag semantics);
  limits (10/socket, 256 chars) are validated at mint — rejected, never
  truncated.

### Realtime authorization lease — hibernated sockets no longer outlive revocation

A ws-ticket was verified once at upgrade, and DO hibernation kept the socket
authorized forever — bans, member removal, and subject revocation never touched
a live connection. Connections now carry a delivery-time **authorization
lease** (default **15 minutes**, from ticket `iat`):

- The fan-out skips expired connections (they receive nothing) and closes them
  with code **4401**; inbound messages — even pings — are enforced the same way.
- **In-place renewal:** `{ type: "reauth", ticket: "<fresh ws-ticket>" }`
  extends the lease and refreshes roles without socket churn. Renewal is
  strict — same `userId`, same scope/org, exact same tag set (tags are
  immutable); identity drift closes with 4401 → reconnect fresh.
- Renewal tickets come from `POST /broadcast/v1/ws-ticket`, which re-runs the
  **full** authorization gate (session, KV revocation, relationship/scope-claim
  probes) — so revocation propagates within one lease period, with no KV reads
  in the Durable Object.
- Configure via `realtime: { lease: '15m' | seconds | false }` (min 60s;
  `false` disables — discouraged). Clients that never reauth degrade gracefully
  to one forced reconnect per lease.

## v0.48.0 — July 8, 2026

### Realtime broadcasts are fail-closed — explicit per-resource audience (BREAKING)

Realtime broadcasting is now opt-in **per resource** with a **mandatory audience**,
mirroring how CRUD is explicit. This closes a fanout footgun where a resource that
never mentioned realtime could still broadcast its rows to an entire room.

- **`realtime.access: { roles: [...] }`** is the audience — same `{ roles }` shape
  as `read.access` / `crud.*.access`. A subscriber receives a live row iff that
  role bucket would admit them at REST. Roles may be concrete membership/scope
  roles or UPPERCASE pseudo-roles (`PUBLIC`, `AUTHENTICATED`, `USER`, `SYSADMIN`).
- **Fail-closed, at compile time** — a broadcasting resource with no/empty audience
  is a **compile error** naming the resource. There is no implicit fanout. To
  broadcast to everyone in the room, say so deliberately with `access: { roles: ['PUBLIC'] }`.
- **Fail-closed, at runtime** — the Broadcaster now treats an **empty audience as
  nobody** (previously: everybody) and evaluates the uppercase pseudo-roles exactly
  as the REST access evaluator does (`userRole` is threaded through the ws-ticket
  for `USER`/`SYSADMIN`).
- **No project-wide auto-broadcast** — `providers.database.config.realtime` now
  only wires the transport (Broadcaster DO + ws-ticket route); it no longer
  enables broadcasts on any resource.
- **Migration** — a project that relied on the project-wide auto-broadcast must add
  an explicit `realtime: { enabled: true, access: { roles: [...] } }` block to each
  resource that should broadcast. `requiredRoles` is the deprecated spelling of
  `access.roles` (still compiles, normalized internally). `q-events` `guests`
  demonstrates the staff-lane pattern (`access: { roles: ['admin','member'] }`,
  event-scoped, masked).

### Deterministic codegen output

The compiler now sorts every filesystem-discovered collection (features, and each
`services.*` group) by a stable key before generating, so recompiles produce
byte-identical output regardless of the CLI's directory-scan order. Eliminates the
churny, behavior-neutral diffs (queue-consumer case order, auth blocks, schema
imports) that appeared across machines/CLI versions.

## v0.47.14 — July 7, 2026

### `acceptScope`: one namespaced handler for the user AND the sessionless principal

A project-level namespace can now **accept an already-minted scope claim as
proof of entry** — the inverse of `mintScope`. Set
**`authz.namespaces.<ns>.acceptScope: '<kind>'`** and the hoisted resolver gains
a fast-path: a scope-only principal (`ctx.scopePrincipal`, no `ctx.userId`)
carrying a verified `ctx.scope.<kind>` claim proves entry by that claim, without
a DB relationship probe. One `/event/:eventId/*` handler then serves **both** the
Better-Auth user and the sessionless invite-principal — retiring the parallel
PUBLIC `/attendee/*` twin.

- **Named-string only** — the kind is always explicit; a bare `true` is a type
  error. Default absent → the resolver never admits a scope principal (fail-closed).
- **Two locks, compile-enforced** — `acceptScope` opts the namespace in, **and**
  at least one relationship lane must confer a `<kind>` role declared
  `subject: 'supplied' \| 'both'`. A `subject: 'user'` role emits no accept arm;
  a kind whose every conferring lane is user-only is a **compile error**, not a
  dead fast-path. The kind must be declared and its `requestField` must equal the
  prefix's first `:param`.
- **Forging-safe** — the claim is verified (never caller-asserted), the kind is
  read by name (not an any-key loop), the instance is pinned twice
  (`entry.id === :param` in the gate + `eq(loads.pk, :param)` in hydration), and
  each accept arm is one exact `(role, lane)` pair. New proof source, zero new
  enforcement surface — firewall, masking, `access`, and scoped-db read the same
  `ctx.scope` they read for a userId caller.
- **Composes with `mintScope`** — a userId caller still gets a rolling re-mint;
  a scope principal's verified claim is preserved (not re-stamped) so its
  `subjectId` / sub-keys survive.
- **Realtime shares the same acceptance** — the `ws-ticket` route now proves a
  scope principal through the *same* emitter as the REST gate
  (`emitScopeAcceptance`), so the two surfaces can't drift. Bringing the ws-ticket
  path onto it tightened it to the named-kind read + supplied-reachable role
  filter, closing an any-kind cross-kind match. `q-events` ships a `vendor` scope
  role (`subject: 'both'`) + logistics-only view demonstrating the sessionless
  supplier end-to-end.
- **Writes work too** — the scoped-db audit wrapper no longer throws when the
  caller is a verified scope principal (`ctx.scopePrincipal`, no `ctx.userId`).
  Instead of failing on the missing user, it stamps the synthetic, audit-only
  principal id (`scope:<kind>:<subjectId>`, the token's `sub`) into
  `created_by` / `modified_by`. So the SAME `/event/:eventId/*` handler body
  serves both a Better-Auth user and the sessionless principal on **reads AND
  writes** — no parallel raw-`db` write path. Still fail-closed: a genuine
  no-user write (no scope principal) throws exactly as before. `q-events` adds a
  `vendorCheckin` write under the acceptScope namespace exercising it end-to-end.

See [Scopes → `acceptScope`](/define/scopes#acceptscope--accept-an-incoming-scope-claim-as-proof-of-entry).

## v0.47.13 — July 6, 2026

### Sessionless scope mint: `ctx.mintScope` + `subject: 'supplied' \| 'both'`

A scope can now be minted for a principal who has **no Better Auth account** — an
event attendee arriving by email + invite code, a kiosk/handout code, a partner
token. The action proves the relationship its own way and asks Quickback to mint
a disciplined scope token from the row it proved.

- **`authz.scopes.<kind>.roles.<role>.subject: 'user' \| 'supplied' \| 'both'`** (default `'user'`).
  `'both'` makes the *same* `scope:event:attendee` reachable by a logged-in user
  (via `/scope/v1/enter`) and a sessionless invite-holder (via `ctx.mintScope`) —
  one firewall arm, one masking rule, both principals.
- **`ctx.mintScope({ via, subject })`** — hydrated on `ctx` in any action (including
  PUBLIC) when a `supplied`/`both` role is declared. The action supplies only the
  conferring row's **primary key**; Quickback reads the role, resource id,
  sub-keys, and subject id **off that row** — never caller-asserted, so the role
  can't be forged. Returns a **scope-only** JWT (no user identity): `ctx.userId`
  stays undefined, so user/org/owner gates and the `AUTHENTICATED`/`USER`
  pseudo-roles all fail closed; only `scope:<kind>:<role>` gates admit it.
- **`ctx.scope.<kind>.subjectId`** — a reserved claim carrying the proven subject
  row's PK, populated on **both** proof paths. Scope an attendee's self-data
  (travel, dietary, todos) with a single firewall arm
  (`{ field: 'guestId', equals: 'ctx.scope.event.subjectId' }`) that serves both a
  user-attendee and a sessionless one.
- **Realtime** — the ws-ticket authorizes a scope-only principal directly off its
  proven `ctx.scope` claim, so accountless attendees join the same rooms with the
  same scope role. The `where:` filter runs on the mint probe, so a cancelled row
  confers nothing (revocation within one ≤180s mint cycle).

See [Scopes → Sessionless scope mint](/define/scopes#sessionless-scope-mint--ctxmintscope).

### MCP OAuth: split authorization-server / resource-server (multi-app SSO)

You can now run one canonical authorization server with many resource servers (each
hosting its own `/mcp`) over a shared auth DB:

- A resource server that sets `agents.mcp.requireAuth` now emits the full OAuth
  access-token **verification** path on its own — previously the JWKS-verify block
  only emitted when the separate `auth.config.oauth.enabled` flag was set, so a
  requireAuth-only resource server fell back to HMAC + session and 401'd every valid
  externally-issued bearer.
- Point a resource server at an external AS with `auth.config.oauth.issuerUrl`; the
  middleware verifies incoming bearers against the shared JWKS (`iss`/`aud`/`exp` +
  live-session binding).
- `auth.config.oauth.audiences` (an array) registers federated resource-server
  audiences on the AS (RFC 8707). It now **unions** with the AS's own origin instead
  of replacing it, so listing federated servers never drops the AS's own `/mcp`
  audience. Also exposed on the plugin-form `oauthProvider.validAudiences`.

See [Agents → Split authorization server](/configure/agents#split-authorization-server--resource-server-multi-app-sso).

### MCP tools: callable path params + typed input schemas

An action mounted under a path that carries a parameter — a namespace prefix like
`/scope/:scopeId/...`, or any `path:` with `:params` — now produces a fully
callable MCP tool:

- The mounted route's path params are extracted into the tool's `inputSchema`
  (and `_pathParams`), so the model has a field to supply each one. Previously
  only record-based actions emitted a param (the hard-coded `id`); a standalone
  or namespace path param was absent from the schema, the URL resolved with an
  empty segment, and the action was uncallable over MCP.
- Standalone routes are stored in Hono colon form (`:scopeId`); the OpenAPI spec
  key (and therefore the tool's `_path`) is now normalized to brace form
  (`{scopeId}`) so the runtime's brace-only path interpolation can fill it.

Action `input` schemas also lower to richer JSON Schema instead of a phantom
empty `{}`: `z.coerce.*`, `z.literal`, unions of literals, and **nested
`z.object`** (child keys stay nested) now emit their real types, so the model —
and the OpenAPI spec — see the actual field shapes. See
[Agents → MCP tools](/configure/agents).

### Scaffold surfaces MCP/OAuth + secrets opt-ins

`quickback create` now seeds the generated `quickback.config.ts` with curated,
commented-out hints for the authenticated MCP server (`agents.mcp.requireAuth`), the
split-AS `oauth.issuerUrl`/`audiences`, and `bindings.secrets` — so these opt-ins are
discoverable in the config itself, not only in the docs.

### Custom secrets via `bindings.secrets`

Declare a custom secret your feature/action code reads off `env` — name and
contract only, never the value:

```ts
bindings: {
  secrets: [
    { name: "WEBHOOK_SIGNING_SECRET", description: "HMAC for webhook callbacks", required: true },
  ],
}
```

It's emitted into the generated `env.d.ts` `Env` interface so `env.<NAME>`
type-checks, and `required` secrets join the boot-time env guard — the worker
fails closed with `503 MISSING_ENV` if one is unset. The value is set
out-of-band (`wrangler secret put`); the schema rejects any inline value so a
real secret can never be committed. See
[Bindings → secrets](/configure/bindings#secrets).

### Auth: `resolve-email-context.ts` hook for per-recipient OTP context

Drop `quickback/hooks/resolve-email-context.ts` and the emailOTP send calls it
per recipient, spreading the returned object into your email templates — branded
palettes (organizer vs. attendee), plan tier, locale, etc. — without patching
generated output. Fail-open, with the Drizzle handle passed in. See
[Auth Hooks → Email context resolver](/ui/account/hooks#email-context-resolver).

### Codegen fixes

- **Schedules:** handler import paths are now rebased on relocation, so a cron
  handler importing `../../features/...` resolves correctly from the generated
  `src/lib/schedules.ts` instead of failing the worker build.
- **OAuth/MCP middleware:** the auth-schema tables are imported under aliased
  names so a function-scoped `const session` can no longer shadow the `session`
  table import — closes a temporal-dead-zone crash that silently 401'd every
  OAuth/MCP bearer.
- **MCP route:** the large tool-data literal is split into a `mcp-tools.ts` data
  module (suppressed) so it can't overrun `tsc`'s type-instantiation budget; the
  `mcp.ts` route logic stays fully type-checked.

### Realtime: identity-gated Durable Object rooms (`roomIdEquals`)

Per-user Durable Object entrypoints (gateway / presence / inbox) can now declare
an identity gate on the binding — `access: { roomIdEquals: 'ctx.userId' }` (or
`'ctx.activeOrgId'`). The compiler emits a Worker-edge gate at
`/realtime/<kebab>/*` that 401s anonymous callers, 400s a missing segment, and
403s unless the decoded room segment equals the server-resolved `ctx` id — a
string compare with no backing table or DB query. See
[PartyServer rooms → Per-room authorization](/platform/realtime/partyserver-rooms).

- Closes the cross-user hole that "no `access` declared" leaves open: a per-user
  room without a gate falls through to the authenticated-only catchall, so any
  authenticated user could open another user's room by guessing the id.
- The gate lives at the edge, not in the DO — a Durable Object has no session
  and only sees client-settable headers, so it must never re-authorize from
  request data (trusting an `x-user-id` header was the original spoof hole).
- Only `'ctx.userId'` and `'ctx.activeOrgId'` are accepted; anything else is a
  compile-time error (fail-closed Zod + generator validation).

### OAuth provider: spurious authorization-server warning silenced by default

The generated `oauthProvider({...})` now emits
`silenceWarnings: { oauthAuthServerConfig: true }` by default. The compiler
always serves the `/.well-known/oauth-authorization-server` discovery route when
the provider is enabled, so `@better-auth/oauth-provider`'s
"please ensure … exists" boot warning was always spurious. Override or extend it
via `plugins.oauthProvider.silenceWarnings`.

### `defineSchedule` — cron jobs from a single source

Declare a cron schedule in `services/schedules/*.ts` and the compiler emits the
Worker's `scheduled()` handler + the matching `[triggers] crons` in
`wrangler.toml` — the trigger-side mirror of `defineQueue`. No separate cron
Worker, no hand-patching the generated entry (which a recompile would strip).
See [Schedules](/platform/schedules).

```typescript
// services/schedules/sweepDigests.ts
export default defineSchedule({
  name: "sweepDigests",
  cron: "* * * * *",
  execute: async ({ db, env, services }) => { await runDigestSweep(db, env, services); },
});
```

- One `scheduled(event, env, ctx)` dispatcher runs every schedule whose cron
  matches `event.cron`; failures are isolated per-schedule.
- The execute context (`{ db, env, services, cron, scheduledTime }`) runs
  server-internal with `ctx.internal = true` and an audit actor of
  `system:cron-<name>`, plus a `withInternalContext` helper for
  `roles: ['INTERNAL']`-gated rows.

### Rate limiting — the fifth security pillar

Per-operation request caps on every CRUD endpoint, backed by Cloudflare's
native [Rate Limiting binding](/define/rate-limit) — no extra
storage, no third-party libraries. Applied to every resource by default
(read 1000/60s, write 200/60s), overridable per-op, per-resource, or
project-wide; `false` opts out at any scope.

- One middleware per feature router maps method→op, keys the limit by
  `<resource>:<op>:<userId>` (falling back to the client IP for anonymous
  callers), and returns `429` + `Retry-After` when over the cap.
- Unique `(limit, period)` tuples collapse onto shared bindings — a project
  that never declares `rateLimit` ships exactly two (`RL_1000_60`,
  `RL_200_60`), emitted as GA `[[ratelimits]]` blocks in `wrangler.toml` and
  typed `RateLimit` on `CloudflareBindings`.
- `period` is compile-time constrained to `10` or `60` (Cloudflare binding
  limit); the check no-ops when a binding is absent, so local dev never
  hard-depends on it.

## v0.47.8 — June 12, 2026

### Postgres-style table triggers (`defineTable` / `feature` `triggers:`)

Tables can now declare `before`/`after` triggers on `insert`/`update`/
`delete`, with two lowering lanes and a per-trigger compile report. See
[Triggers](/define/triggers) for the full reference.

- **`sql:` entries** compile to real SQLite `CREATE TRIGGER` statements,
  shipped in the journaled features migration (content-addressed — an
  unchanged trigger set appends nothing; removing a trigger drops it on the
  next migrate). They fire for **every** write path including raw SQL, run
  atomically with the triggering statement (the only transactional option
  on D1), support `when:` guards and `RAISE(ABORT, ...)` rejection, and
  `OLD.`/`NEW.` column references are validated at compile time. The
  compiler owns the `BEGIN`/`END` frame — emitted uppercase, since
  lowercase `begin` breaks remote D1's statement splitter. On soft-delete
  tables, `*Delete` events map to the `deleted_at` transition (plus a
  hard-delete variant) and `*Update` events exclude it, so a soft delete
  fires delete triggers exactly once. D1 only.
- **`handler:` entries** run as application hooks at the audit-wrapper
  write chokepoint (REST CRUD + actions). `before*` hooks are synchronous
  and can mutate the payload or throw to reject — the write never executes
  and the request gets `TRIGGER_REJECTED` (422); audit fields are
  re-stamped after hooks and cannot be forged. `after*` hooks run once per
  returned row and are **awaited** — a throw surfaces as
  `TRIGGER_AFTER_FAILED` (500) with `details.committed: true` since the
  write already persisted. Handlers get `{ table, op, values|row, ctx, db,
  env }`; the `db` is audit-wrapped at cascade depth + 1, so hook writes
  stamp audit fields, emit live-view deltas, and fire nested triggers,
  bounded by a depth cap of 5. Each table's drizzle object is auto-imported
  into the generated registry under its authored name, and handler imports
  are usage-filtered and hoisted.

The compile output includes a lowering/coverage report and fail-closed
warnings: `handler:` triggers do **not** fire for queue handlers, cron, or
raw SQL — guards that must hold universally should use `sql:` +
`RAISE(ABORT)`. `async: true` (queue-dispatched after-hooks) and
`withOld: true` (previous-row access) are reserved and rejected at compile
time until they ship.

Also in this change:

- New `trigger` error layer with `TRIGGER_REJECTED` / `TRIGGER_AFTER_FAILED`
  codes in the generated error catalog, OpenAPI spec, and SDK types.
- Parser: function-valued properties in `defineTable`/`feature` configs now
  parse as compile-time source carriers instead of failing the whole config
  with "no dynamic expressions" — unblocks trigger handlers (and
  function-form `access:` / custom masks) through the CLI source path.
- Fixed a column-injection bug where a comment merely mentioning a column
  name (e.g. `"deleted_at"` in a docblock) suppressed audit/soft-delete
  column injection for the table.
- Fixed a silent no-op: a project declaring `services/queues/*.ts` handlers
  without embeddings or `additionalQueues` deployed a wired `queue` export
  with no `[[queues.consumers]]` to feed it — the handlers were dead code.
  The primary queue (and its typed env binding) is now emitted for
  handlers-only projects, with a compile notice that Cloudflare Queues
  requires the paid Workers plan. Queue emission remains strictly opt-in by
  usage: projects with no queue-backed features (embeddings, queue
  handlers, `additionalQueues`, webhooks) emit no queue configuration and
  deploy on the free plan.

## v0.47.7 — June 12, 2026

### Envelope encryption — `q.text().encrypted()` (encryption pillar, Phase 1)

Mark a text column `.encrypted()` and it is AES-256-GCM-encrypted at rest
under a **per-organization key**: a database breach (backup, export, leaked
token, SQL injection, insider read) yields only ciphertext for that column.
See [Encryption](/define/encryption).

- Per-org DEKs live wrapped under the `ENCRYPTION_KEK` worker secret in a
  compiler-owned `org_data_keys` table (lazily created, versioned for
  rotation). Deleting an org's row **crypto-shreds** every value it wrote.
- One write chokepoint: CRUD, batch, and action-layer `db.insert/update`
  all encrypt through the audit wrapper — audit snapshots, webhook
  payloads, and realtime deltas carry ciphertext by construction.
- Reads decrypt behind the firewall then apply masking; encrypted columns
  are compile-time excluded from `?search`/`?filter`/`?sort`, view query
  allowlists, and embedding sources.
- `ctx.crypto.encrypt/decrypt` for explicit server-side processing in
  actions; boot guard refuses to serve without the KEK (503 `MISSING_ENV`);
  scaffolded `.dev.vars` now includes a generated `ENCRYPTION_KEK`.
- The sealed (E2EE) and vault tiers remain compile-gated until built —
  roadmap in `apps/compiler/research/encryption-vault-spec.md`.

## v0.47.0 — June 11, 2026

### Breaking: zero-false-positive analyzer checks are now compile errors

The authz/authn analyzer (v0.37) shipped warn-only. After a release cycle of
data, the checks whose every trip is a definite misconfiguration — never a
legitimate config — are **promoted to compile errors**. Warnings nobody is
forced to read are documentation; the pillar guarantees are now enforced.

**Breaking** — a config that previously compiled with one of these warnings
now fails the compile:

- `AUTHZ_EMPTY_PERMISSION` — a permission that is structurally unsatisfiable
  (`allOf(a, not(a))`: a fact AND its exact negation at the same AND-level).
- `AUTHZ_CAPABILITY_CEILING` — a resource's firewall grants a
  `scope:<kind>:<role>` role access beyond its capability profile (or the
  role has no profile at all). This is the M0.2 pillar guarantee — it was
  authored as an error all along and is now enforced.
- `AUTHN_ROUTE_COVERAGE`, `AUTHN_SURFACE_INVARIANT`,
  `AUTHN_SECRET_CONSISTENCY`, `AUTHN_CREDENTIAL_DELIVERY`,
  `AUTHN_CLAIM_PROVENANCE` — the structural-authn set (route auth coverage,
  bearer-only `/mcp`/agent surfaces + INTERNAL unreachability, mint↔verify
  key/issuer/audience pairing, anchored credential delivery, verifier-only
  identity claims). These still run against empty route/credential metadata
  on a real compile (no-ops until the generators emit it), but the severity
  contract is locked in now — they fail the build the moment they can fire.

The heuristic/advisory checks are unchanged: `AUTHZ_SHADOWED_ARM`,
`AUTHZ_UNREACHABLE_ROLE`, `AUTHZ_UNREACHABLE_VIEW`, and
`AUTHZ_NOT_OVER_RESOURCE` stay warnings; `AUTHZ_PUSHDOWN_COST` stays an info
report. `QUICKBACK_AUTHZ_STRICT=true` still promotes those warnings too.

**The override escape hatch** — every promoted finding can be explicitly
accepted, per finding, with a required reason:

```ts
authz: {
  analyzer: {
    allow: [
      {
        check: 'AUTHZ_CAPABILITY_CEILING',
        target: 'sessions/scope:event:organizer',   // from the rendered finding
        reason: 'organizer grant profile lands with the v2 rollout (TICKET-123)',
      },
    ],
  },
}
```

An override downgrades that exact `(check, target)` finding back to a warning
that cites the reason — even under strict mode (it's an explicit, reasoned
acceptance). Every rendered error prints its exact override recipe. Guard
rails: `check` must be one of the promoted ids (unknown or keep-warn ids are
rejected at config validation), `reason` is required, and an `allow` entry
that matches no current finding is itself a compile error — no dead
overrides accumulating after the underlying issue is fixed.

Analyzer-internal defects are still fail-open: a bug in the analyzer itself
downgrades to a single warning and the compile continues. Only real findings
fail builds.

### CLI: live views now compile through the CLI

`defineView` files under `quickback/services/views/` were never loaded by
the CLI — the compile payload simply omitted them, so
[live views](/platform/realtime/live-views) (v0.35) only worked when driving
the compiler API directly. The CLI now discovers and ships them like every
other service definition, and the compile summary counts them
(`Loaded services: … 1 live view(s)`). If you declared views and wondered
why no surface ever went live: upgrade the CLI and recompile.

### CLI: deterministic compile output across machines

Feature and service discovery used raw `readdir` order, which differs
between macOS and Linux — route registration order, schema re-exports,
OpenAPI paths, and the Neon RLS document's content hash could all churn when
the same project was recompiled on a different machine. All discovery sites
now sort by codepoint, so the same input compiles to the same bytes
everywhere. Expect a one-time diff on the first recompile if your previous
output was generated in a different filesystem order.

### Fixes

- Read-only resources (no `crud` block) generated routes importing a
  `CRUD_ACCESS` symbol their resource file never exported — a tsc/bundle
  break. The export is now unconditional.
- The `blog` starter template gated writes on the removed `ADMIN`
  pseudo-role, which hard-fails current compiles; it now uses the
  org-membership `admin` role.
- `reports/operations.manifest.json` and `reports/conformance.manifest.json`
  now also cover the realtime (`/broadcast/v1`), storage (`/storage/v1`),
  and FGA (`/fga/v1`) surfaces.
- The dead free/pro tier plumbing is gone: no Tier row in
  `quickback whoami`, no upsell copy at login. Compiling complete projects
  requires login, exactly as before — there was never a paid gate behind it.
- The CLI is published to npm via trusted publishing (GitHub OIDC) — signed
  provenance, no long-lived npm token in the pipeline.

## v0.46.0 — June 11, 2026

### Breaking: "no organization context" responses unified on HTTP 403

Generated APIs used to answer the same semantic condition — *the caller has no
active organization context* — with two different statuses: `400 ORG_REQUIRED`
from the collection list guard and the create/PUT/batch org validation, but
`403 ACCESS_NO_ORG` from standalone actions and `403
ACCESS_TENANT_SCOPE_REQUIRED` from unsafe org-scoped actions. Clients and SDKs
had to special-case the split.

**Breaking** — recompile + redeploy required; every "no org context" denial is
now **403**:

- `GET /api/v1/<resource>` (and views) without an active org or
  `?organizationId=` → `403 ORG_REQUIRED` (was 400).
- `POST /`, `PUT /:id`, `POST /batch`, `PUT /batch` without an active org →
  `403 ORG_REQUIRED` (was 400).
- Standalone actions (`403 ACCESS_NO_ORG`) and unsafe org-scoped actions
  (`403 ACCESS_TENANT_SCOPE_REQUIRED`) were already 403 and are unchanged.

Error **codes are unchanged** — `ORG_REQUIRED`, `ACCESS_NO_ORG`, and
`ACCESS_TENANT_SCOPE_REQUIRED` keep their identities, so clients that
discriminate on `code` need no changes. Only clients that branch on the raw
`400` status for these responses must update to `403`.

### Breaking: root-mounted actions lose their nested alias URLs

A standalone action whose declared `path:` lives outside its feature prefix
(e.g. `path: "/reports/orders"` in the `orders` feature) mounts at the root:
`/api/v1/reports/orders`. That root URL is the one the OpenAPI spec, the MCP
surface, and the operations manifest have always advertised — but the
generated per-feature routes file *also* carried an undocumented, fully-gated
copy at the nested `/api/v1/<feature><path>` URL (e.g.
`/api/v1/orders/reports/orders`).

**Breaking** — the nested alias is no longer emitted. One URL per operation:
a root-mounted action exists only at its advertised `/api/v1<path>` mount.
Anything calling the unadvertised nested URL gets a 404 after recompile +
redeploy; switch to the documented root URL. Actions declared *under* the
feature prefix are untouched, and a feature whose standalone actions are all
root-mounted no longer emits an (empty) feature actions routes file.

### Behavior change: `PUBLIC` inside `or:` arms admits anonymous callers

[Access](/define/access) has always documented `PUBLIC` as
working on every access node, but the emitted auth gate only honored it in a
flat `roles: [...]` list. A tree like

```ts
access: {
  or: [
    { roles: ["PUBLIC"] },
    { roles: ["admin"] },
  ],
}
```

still answered anonymous callers with `401`. The auth gate is now tree-aware
and matches the documented semantics: a `PUBLIC` `or:` arm admits anonymous
callers past the authentication gate (an `and:` group admits them only when
*every* arm does), and the access / org / firewall layers still evaluate the
full tree. One shared predicate now drives the emitted gates, the OpenAPI
`security` blocks, and view routes (which gate per view on the
view-effective access).

**Worth a loud note:** if you have a nested `PUBLIC` arm that was silently
401-ing, that endpoint becomes anonymously reachable after recompile —
exactly what the config declares, but verify it's what you meant. `PUBLIC`
audit logging applies to the newly-admitted routes as usual.

### Realtime broadcasts now enforce `requiredRoles` and masking

A table's declared realtime gates weren't being applied to the generated
CRUD broadcasts: `realtime: { requiredRoles: [...] }` and column masking were
honored on the HTTP surface but not on the WebSocket fanout. The docs
promised per-role, masked realtime delivery; the generated broadcasts now
match it. **Recompile and redeploy** any project using realtime on
role-gated or masked tables.

Generated `realtime.insert / update / delete` calls now carry:

- **`targetRoles`** — the table's `realtime.requiredRoles` (post role-
  hierarchy `+` expansion), enforced per subscriber at fanout.
- **`maskingConfig`** — serialized from the same effective masking
  (declared + auto-detected sensitive columns) the HTTP mask functions are
  generated from. What HTTP masks is what the WebSocket wire masks.

Live-view `view_changes` deltas honor the same table-level `requiredRoles`
at fanout, so a role-gated table can't leak through a
[live view](/platform/realtime/live-views) subscription either.

Notes:

- Recompile suffices — no config change needed.
- If you worked around this with hand-written `realtime.*` calls passing
  `targetRoles` / `maskingConfig`, those workaround broadcasts can be
  removed.
- `type: 'custom'` mask functions can't ride the wire — the Broadcaster
  fails closed and broadcasts `[REDACTED]` for those columns.
- Tables without `requiredRoles` or masking are byte-identical: the open
  broadcast default is unchanged.

### Batch operations inherit the base operation's access

When a batch operation was declared without its own `access`, it didn't pick
up the base operation's role requirement:

```ts
crud: {
  update: { access: { roles: ["admin"] } },
  batchUpdate: { maxBatchSize: 25 },   // ← no access of its own
}
```

A batch config declared without `access` now inherits the base operation's
access at compile time — fail closed, so runtime, the OpenAPI spec, and the
generated code all agree. Explicit batch `access` still wins (including
per-side on upserts); a base op without access still yields an
authenticated-only batch op. The flat `update: { batch: true }` shape and
auto-promoted batch ops were never affected. **Recompile and redeploy** if
you use the explicit-batch shape without per-batch access.

### Record actions, PUT upserts, and legacy firewall arms fail closed

Three fail-closed hardening fixes, found and verified against the new
conformance catalog (route × principal expectations derived from the
compiler's own IR — see the
[conformance manifest](/api/openapi#operations-manifest)).
All apply on recompile + redeploy; no config change needed:

- **Record actions evaluate the role gate before fetching the record**, so
  the denial response no longer varies with record state — a uniform
  `403 ACCESS_ROLE_REQUIRED`. (Bulk actions already had this order; unsafe
  actions write their audit row on the pre-record denial.)
- **PUT upsert treats an out-of-scope id as a firewall miss** (`403` reveal /
  `404` hide, per your firewall error mode; a standard 207 miss entry on
  `PUT /batch`) instead of routing it into the create path. Genuinely new
  ids keep the create path.
- **Legacy (array-form) firewall arms deny cleanly when a bound context
  claim is absent at runtime** (e.g. no active org), rather than surfacing a
  database error — the same deny-when-absent behavior the explicit `all:` /
  `any:` group forms already had.

### Root-mounted standalone actions use the standard gate stack

Standalone actions mounted outside their feature prefix
(`path: "/reports/orders"`) were emitted through a separate code path that
did not apply the full access / audit / scoped-database treatment that
in-feature standalone actions receive. They now render through the same
generator, so the same access checks, security-audit writes, and
tenant-scoped database wrapping apply everywhere. If you use root-mounted
standalone actions, recompile and redeploy. (Unifying the two code paths is
also what surfaced the two breaking changes above.)

### Namespace routing fixes

- An action whose declared `path` exactly equals its namespace prefix is now
  claimed by the namespace like any nested action (Hono's `/x/*` mount
  already matched the bare `/x`) — it's served through the namespace's gate
  instead of carrying a second inline copy of the resolver.
- A namespace with zero claimed standalone actions (e.g. record-based-only)
  no longer mounts its middleware at all — a dead gate can no longer
  intercept requests under its prefix.

### Custom org roles work with Better Auth member management

Custom roles referenced in access trees (e.g. `roles: ["support"]`) are now
auto-registered with the Better Auth organization plugin. They were already
enforceable on routes but unprovisionable through Better Auth —
`invite-member` / `update-member-role` rejected them with
`400 ROLE_NOT_FOUND`, so there was no supported way to actually give a
member the role. Auto-registered roles carry member-equivalent permissions
(this is about assignability; your access trees remain the authorization
model). If you pass your own `roles` / `ac` to the organization plugin
options, those win and auto-registration is skipped. Built-in, pseudo, and
`scope:` roles are excluded; projects with no custom roles emit
byte-identical output.

### snake_case tables: dropped record actions and broken helpers fixed

Two fixes for tables declared with snake_case names (`event_notes`):

- The generated `.quickback/define-action.ts` helpers imported the camelCase
  identifier while the generated alias file exports the verbatim declared
  name (`export { event_notes }`) — a TS2724 compile failure for any
  snake_case table with action files. The helpers now import the real export
  aliased to the camelCase local.
- Record actions on a snake-declared table living in a kebab-named feature
  file (`event-notes/actions/pin.ts`) were not emitted — missing from both
  the generated routes and OpenAPI. They now emit and document consistently.

If you have snake_case tables with record actions, recompiling **adds routes
that previously didn't exist** — review the new surface.

### First-class MCP OAuth config: `auth.oauth`

The OAuth 2.1 surface behind `agents.mcp.requireAuth` (v0.42.0) gains a
first-class config block — env-resolvable issuer / login / consent URLs,
scopes, RFC 8707 audiences, and a dynamic-client-registration toggle:

```ts
auth: defineAuth("better-auth", {
  oauth: {
    enabled: true,
    issuerUrl: { env: "OAUTH_ISSUER_URL", fallback: "http://localhost:8787/auth/v1" },
    loginPage: { env: "OAUTH_LOGIN_URL", fallback: "http://localhost:8787/account/login" },
    consentPage: { env: "OAUTH_CONSENT_URL", fallback: "http://localhost:8787/account/consent" },
    scopes: ["openid", "profile", "email", "offline_access"],
    audiences: [{ env: "OAUTH_MCP_AUDIENCE", fallback: "http://localhost:8787/mcp" }],
    allowDynamicClientRegistration: true,
  },
}),
```

Setting it auto-enables the `oauthProvider`, `jwt`, and `bearer` plugins,
emits the RFC 8414 / RFC 9728 discovery documents, and accepts the
provider's JWKS-signed access tokens in the standard auth middleware. A new
project hook, `quickback/hooks/resolve-oauth-context.ts`, lets you derive
tenant fields (`activeOrgId` / `activeTeamId` / roles) from the validated
OAuth identity. On Neon, the auth schema is now owned by the Better Auth CLI
end to end (the compiler no longer emits its placeholder `src/auth/schema.ts`
or `src/lib/neon.ts`). See
[OAuth Provider (MCP-ready)](/platform/auth/plugins#oauth-provider-mcp-ready)
and [Auth Hooks](/ui/account/hooks#oauth-context-resolver).

### Neon RLS covers force-enabled plugin tables

On Neon, row-level-security policies are generated for the Better Auth
tables. The list of tables receiving policies was derived from your declared
auth config and didn't account for plugins the compiler force-enables
automatically — so some auth-owned tables could be created without RLS
policies. The list now derives from the fully resolved plugin set, so every
generated auth table receives appropriate policies (self-scoped where there
is a clear owner, service-role-only otherwise — fail closed). The RLS
migration is content-addressed, so the update arrives as a new journaled
entry on the next recompile + `db:migrate`. **Neon projects should recompile
and run `db:migrate`.**

### New compile reports: operations + conformance manifests

Every compile now writes two machine-readable reports next to
`openapi.json`:

- **`reports/operations.manifest.json`** — a stable, sorted index of every
  operation: `{ operationId, method, path, auth, feature, kind }`, covering
  the resource, action, view, auth, realtime, storage, and FGA surfaces. An
  extend-only public contract — the recommended integration point for
  scripting against the generated surface.
- **`reports/conformance.manifest.json`** — for each route × principal
  class, the expected outcome (allow / 401 / 403 / cross-tenant miss …),
  derived from the same IR the emitters consume. This is the artifact the
  conformance fixes above were found and verified against.

The OpenAPI generator itself now reads the same IR as the route emitters, so
the spec follows the emitted surface by construction — including four
accuracy fixes: `PUT /{id}` is documented only when actually enabled, the
batch surface documents the emitter's auto-promotion + access inheritance,
and views document their effective (view-level, falling back to read-level)
access. See
[Operations manifest](/api/openapi#operations-manifest).

## v0.45.0 — June 9, 2026

### `auth.jwt.revocationCheck: 'kv'` — sub-TTL JWT revocation

The JWT fast-path trusts signed claims for their full lifetime — that's what
makes it a fast-path. Until now the only revocation bounds were the TTL
(plain claims) and the carry-forward cap (scope claims, v0.44.0). The
long-promised `'kv'` mode is now real on the **Cloudflare runtime**:

```ts
auth: { jwt: { revocationCheck: 'kv' } }
```

- **Per-principal revocation timestamps, not per-token denylists.** A
  revocation event writes "everything for this principal minted before now is
  dead" to the project's **existing `KV` binding** (keys prefixed `qbrev:`) —
  nothing extra to provision. Entries self-expire after the maximum token
  lifetime, so the store never accumulates.
- **The bearer fast-path checks before trusting.** A verified token whose
  `iat` (or `sct`, for the relationship-scope claim) predates the stored
  timestamp is rejected and falls through to session auth, which re-checks
  membership and re-mints — an active legitimate session recovers
  transparently. Costs 2+ edge-cached KV reads per bearer verify; the
  explicit trade the opt-in buys.
- **Written automatically where revocation actually happens:** org-member
  removal and role change (Better Auth `organizationHooks`), and
  UPDATE/DELETE of scope-conferring relationship rows — through the same
  audit-wrapper write chokepoint Live Views uses, plus the single DELETE
  route (which has the row in hand but no `.returning()`). Revoke-on-touch:
  a false positive just forces a cheap re-prove.
- **User bans flip automatically.** The Better Auth admin plugin's ban lands
  as a user-row update, so a generated `databaseHooks.user.update.after` hook
  stamps the user-level revocation the moment `banned` flips — every
  outstanding JWT dies on its next use, and the cookie/session path already
  rejects banned sessions, completing the lockout. When outbound webhooks are
  configured, the same hook emits a `user.banned` event so subscribed
  endpoints are notified.
- **App-code helpers** for everything else: `src/lib/jwt-revocation.ts`
  exports `revokeUserTokens` / `revokeMemberTokens` / `revokeScopeTokens`
  (e.g. call `revokeUserTokens` from a custom revoke-all flow).
- **Fails open.** A KV outage degrades revocation to the TTL /
  carry-forward-cap bound — it never locks every bearer out.

Bun/Node + `'kv'` fails at compile time with a clear message (the store rides
the Cloudflare KV binding; those runtimes own immediate-revoke via the
cookie/session path). Default remains `'none'`; without the flag the emitted
middleware is byte-identical to v0.44.0.

### Neon: `npm run db:migrate` now applies the RLS layer

The RLS SQL (helper functions, policies, triggers) used to be emitted as
orphaned `drizzle/migrations/0099–0102` files that no tool ever applied —
`db:migrate` ran the schema migrations and silently skipped the security
layer. The RLS document now rides the journaled drizzle set in
`quickback/drizzle/` as a content-addressed migration
(`<idx>_quickback_rls_<hash>`), so one `npm run db:migrate` applies schema
**and** security, in dependency order. The migration is idempotent
(`DROP POLICY IF EXISTS` + re-create), so it converges over databases where
the old files were applied by hand; an unchanged security config appends
nothing (the journal stays byte-stable across recompiles), and a changed one
appends a new content-addressed entry — append-only, like every other
migration. Existing Neon projects: recompile + `db:migrate`, nothing breaks.

### Neon: `user_sessions` is real, and the SQL layer speaks TEXT ids

`get_active_org_id()` — the helper every org-scoped RLS policy reads — was
querying a `public.user_sessions` table that **no migration created**, and
the helper layer was typed `uuid` against Better Auth's TEXT ids
(`org_abc123`), which would throw on first contact. Now:

- `user_sessions` is compiler-owned migration state
  (`src/db/rls-support.schema.ts`), created by the normal journaled flow
  with a FK to the users table — outside `src/auth/schema.ts`, which the
  Better Auth CLI rewrites.
- Generated Better Auth session hooks mirror the session's active
  organization (and team, in teams mode) into it on create/update.
  Switching to "no org" nulls it through — SQL-side access revokes, fail
  closed.
- The RLS helper layer (`get_active_org_id`, `get_active_team_id`,
  `is_owner`, the audit helpers) is TEXT-typed to match Better Auth ids.
- Policies for Better Auth's own tables use the plural physical names the
  BA CLI actually generates on Neon, and BA tables no longer get Quickback
  audit triggers for columns they don't have.

### Hard deletes wire into the audit pipeline correctly

Two codegen bugs around `delete: { mode: 'hard' }`:

- Firewall auto-detection emitted an `isNull(deletedAt)` predicate for
  tables with no soft-delete column — every read on a hard-delete table
  referenced a column that doesn't exist.
- Hard-delete routes import the `logHardDelete` audit helper, but the helper
  file was only emitted when the project also had unsafe or `PUBLIC` actions
  — a project with hard deletes and neither failed at deploy time. Hard
  deletes are now a first-class trigger of the security-audit pipeline
  (helper file, `AUDIT_DB` binding, env types, audit migration); on D1 that
  means [`auditDatabaseId`](/configure/providers) is required, and the
  compile error says so.

### Generated output is now typechecked — with the first crop of fixes

Every compiler change now compiles a matrix of fixture projects and runs
`tsc` + `wrangler deploy --dry-run` against the **generated output** — the
class of bug where the compiler emits syntactically valid TypeScript that
fails downstream. The first runs caught real ones, all fixed here:

- **Neon:** `src/db/org.schema.ts` re-exported tables that don't exist after
  the Better Auth CLI's plural rewrite; a missing `AuthDatabase` type
  export; transaction callbacks typed bare `(tx)` failing `noImplicitAny`;
  the auth middleware's member-table fallback hardcoded the singular name.
- **Embeddings:** the queue consumer's `Ai.run` result and the optional
  `EMBEDDINGS_QUEUE` binding both failed strict checks — the route now
  returns a structured `503` when the producer binding is missing instead
  of crashing.
- **snake_case relationship tables:** relationship/arrow emitters imported
  the camelCase identifier where the generated file exports the verbatim
  table name (`export { event_guests }`) — TS2724 for any snake_case
  relationship table. Imports now alias the real export
  (`event_guests as eventGuests`).

## v0.44.0 — June 9, 2026

### Scope carry-forward is now bounded (revocation fix)

The auth middleware's rolling refresh re-signs a verified `scope` claim on
every authed call without re-probing the relationship — that's what lets a
scoped session ride past the short JWT TTL with zero per-request DB cost. But
unbounded, it defeated the revocation model the docs promised: a removed
guest/staff who kept polling got a fresh TTL on every request, so "removal
takes effect within one TTL" only held for *idle* clients, and the refresh
also bypassed the ≤180s scope-token ceiling (it re-signed with the raw
`auth.jwt.expiresIn`).

Scope tokens now carry a proof timestamp (`sct`), stamped exclusively by the
proven mint sites (`POST /scope/v1/enter` and the namespace resolver, via the
shared mint helper). The rolling refresh:

- **refuses once the claim's proof is older than the carry-forward cap
  (15 minutes)** — past it, the token expires within one TTL and the client
  re-proves via `enter` or any minting namespace route, which won't re-mint a
  revoked role. Worst-case post-revocation window for an active client:
  **cap + one TTL** from the last real proof, instead of unbounded.
- **carries `sct` forward verbatim** — the refresh can never restart the
  window without an actual relationship probe. Claims without `sct`
  (pre-0.44 tokens) fail closed and simply stop refreshing.
- **signs through the same ≤180s ceiling as the mint sites** — an
  `auth.jwt.expiresIn` above the ceiling no longer leaks into scope-bearing
  tokens via the refresh path.

No client changes needed: tokens renew exactly as before inside the window,
and re-entering is the existing flow. For tables where even the capped window
is too wide, gate with a `via` arm (live relationship subquery per request)
instead of a bare `ctx.scope.<kind>` arm; immediate revocation
(`auth.jwt.revocationCheck: 'kv'`) remains on the roadmap.

## v0.43.0 — June 4, 2026

### Better Auth 1.6

Generated projects are now pinned to Better Auth **1.6**. The `agents.mcp.requireAuth` work in 0.42.0 surfaced a long-standing version drift: the compiler emitted `better-auth: ^1.5.0`, but `@better-auth/oauth-provider` resolves a tree that requires `@better-auth/core ^1.6.14` and imports `@better-auth/core/utils/host` + `/redirect-uri` — subpaths that only exist in 1.6.x. The result was a `wrangler deploy` that failed at bundle time with `Could not resolve …` and never uploaded.

This release aligns the **entire** Better Auth dependency set to a coherent 1.6.9 floor so npm resolves one consistent tree:

- `better-auth`, `@better-auth/drizzle-adapter`, `@better-auth/api-key`, `@better-auth/passkey`, `@better-auth/oauth-provider` → `^1.6.9` (generated projects, the baked Docker deps, and the bundled CMS/Account SPAs).
- Added an explicit `kysely ^0.28.16` pin: Better Auth 1.6's migrator imports `DEFAULT_MIGRATION_TABLE` / `DEFAULT_MIGRATION_LOCK_TABLE` from kysely but doesn't *declare* kysely, so the Worker bundle needs it pinned directly.

No code migration was required — Quickback already uses `@better-auth/oauth-provider` (not the now-deprecated OIDC Provider plugin), and emits no `freshAge`/SAML usage affected by 1.6's behavioral changes. Verified end to end: a `requireAuth` project and a normal project both `npm install` a clean 1.6.14 tree and bundle with `wrangler deploy --dry-run` with zero resolution errors.

### Larger migration histories no longer hit the upload ceiling

Long-lived projects accumulate one full-schema drizzle snapshot per migration, and the whole cumulative meta payload (snapshots + journals + `.sql`, across both the auth and features dirs) is re-sent on **every** compile — even a no-schema-change one. Projects with deep migration histories were hitting the compiler's `existingFiles` size limit and failing to compile, with no schema drift and nothing to fix on their end.

The server-side ceilings are raised so this is no longer the binding constraint:

- `existingFiles` payload: **4 MiB → 16 MiB**
- total request body: **8 MiB → 32 MiB** (kept above the per-category limits so it doesn't trip first)

This is enforced entirely on the compiler, so no CLI upgrade is needed — the new headroom applies as soon as `compiler.quickback.dev` is on this release.

### Custom response headers (`security.headers`)

The generated Worker emitted a fixed set of security headers (CSP, HSTS,
`X-Content-Type-Options`, …) with no supported way to add your own — teams that
wanted, say, an `X-Server-Version` build stamp on every response had to
post-patch the generated middleware after each compile.

New `security.headers` sets arbitrary response headers natively. Values are
static strings or templates interpolating per-deploy Worker env vars via the
`{{env.VAR_NAME}}` token:

```ts
security: {
  headers: {
    // Both vars must be set, or the header is omitted entirely.
    'X-Server-Version': '{{env.SERVER_VERSION}}+{{env.SERVER_BUILD}}',
    'X-Powered-By': 'Quickback',
  },
}
```

- **Omit-if-unset** — a header that references env vars is emitted only when
  *every* referenced var is set (non-empty) at runtime. A Worker that forgot to
  inject the var emits no header, never `X-Server-Version: undefined`. Static
  headers are always emitted.
- The value is read from `c.env` per request, so it picks up whatever Worker
  var is injected at deploy time — nothing is baked at compile time.
- **Managed headers are rejected** — names owned by a dedicated `security` field
  (CSP, HSTS, `X-Frame-Options`, `Referrer-Policy`, …) error at compile so the
  two emitters can't race. Malformed `{{env…}}` tokens error too, rather than
  leaking literal braces into the wire header.

Survives a clean `quickback compile` with no post-processing. See
[Security Headers → Custom response headers](/configure/security#custom-response-headers).

## v0.42.0 — June 4, 2026

### Interactive OAuth for the generated MCP server

MCP connectors (Claude, Cursor, …) could only connect to a generated `/mcp`
endpoint **anonymously** — they never launched a sign-in flow, so auth-gated
tools stayed invisible. The OAuth machinery was already present (Better Auth's
`oauth-provider` ships `/oauth2/authorize`, `/token`, `/register` + PKCE under
`/auth/v1`); the compiler just never let a connector discover or trigger it.

New `agents.mcp.requireAuth` makes the MCP surface authenticated-only:

```ts
agents: { mcp: { requireAuth: true } },
```

When enabled, the compiler:

- **Challenges unauthenticated `/mcp`** with `401` + `WWW-Authenticate: Bearer
  resource_metadata="…"` ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) §5.1) — the signal connectors use to start OAuth.
- **Force-enables `oauthProvider` + `bearer`**, so `/oauth2/authorize`, `/token`,
  and `/register` ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) dynamic client registration) exist with PKCE.
- **Delegates `/.well-known/oauth-authorization-server`** to Better Auth's real
  metadata instead of a hand-rolled stub that advertised a non-existent
  authorization endpoint.
- Adds the `<origin>/mcp` resource to `validAudiences`
  ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)) so the connector's access token isn't rejected.

Login and consent are served by your Account SPA, so users authenticate with
whatever methods you already enabled. Default is unchanged (anonymous public
tools); set `requireAuth: true` for servers meant for authenticated users.

## v0.40.2 — June 3, 2026

### Passwordless plugins can no longer bypass the signup gate

Better Auth's `emailOtp` and `magicLink` plugins auto-create an account when a
code/link is verified for an unknown email — and that path is independent of
`emailAndPassword.disableSignUp`. As a result, `account.auth.signup: false`
closed `POST /auth/v1/sign-up/email` but left OTP / magic-link verification as an
open back-door for account creation.

The compiler now emits each plugin's own `disableSignUp` flag, and new
per-plugin options expose it directly:

```ts
auth: defineAuth("better-auth", {
  emailAndPassword: { enabled: true },
  plugins: {
    emailOtp: { disableSignUp: true },   // OTP sign-in only; unknown emails rejected
    magicLink: { disableSignUp: true },  // same for magic links
  },
}),
```

Both default to following the global gate: setting `account.auth.signup: false`
(→ `emailAndPassword.disableSignUp: true`) now also gates the OTP and magic-link
paths automatically. Set `{ disableSignUp: false }` on a plugin explicitly to
keep its sign-up open while password sign-up stays closed.

## v0.40.1 — June 2, 2026

- The generated `wrangler.toml` and `Env` type now emit the `ASSETS` binding
  type for projects that mount SPAs (CMS / Account), so handler code that
  references `c.env.ASSETS` typechecks.
- Compiler Docker builds preserve their layer cache across rebuilds, cutting
  local recompile time.

## v0.40.0 — June 2, 2026

R2 file storage is now **presign-only by default**. Adding
`defineFileStorage("cloudflare-r2")` emits just the `ctx.storage` signer — no
FILES_DB database, no `/storage/v1/*` endpoints, no files worker. You sign
presigned URLs against a bucket and store references in your own table. The
turnkey managed subsystem is still available, now behind `managed: true`.

This decouples the lightweight primitive from the heavyweight subsystem so you
can add uploads without standing up a metadata database you don't use.

### Presign-only by default; managed is opt-in

```ts
// Presign-only (default) — ctx.storage against a bucket, nothing else
defineFileStorage("cloudflare-r2", { bucketName: "my-app-media" })

// Managed subsystem — FILES_DB + /storage/v1/* + files worker
defineFileStorage("cloudflare-r2", { managed: true })
```

- New projects can omit `bucketName` — it defaults to `<project>-media`, so
  setup is one `wrangler r2 bucket create` plus the R2 API-token secrets.
- Existing projects set `bucketName` to a bucket they already have. Presign-only
  emits **no** R2 bucket binding and **no** FILES_DB binding — nothing to
  provision, nothing to migrate.

### Per-call bucket + custom secret names

```ts
// Sign against a different bucket than the configured default
await storage.signPutUrl(key, { bucket: "my-app-public", contentType });
```

If your project already stores R2 credentials under different env-var names,
map them so the signer reads yours:

```ts
defineFileStorage("cloudflare-r2", {
  bucketName: "my-app-media",
  presign: { accountIdEnv: "CF_ACCOUNT_ID", accessKeyIdEnv: "S3_KEY", secretAccessKeyEnv: "S3_SECRET" },
})
```

### Fixes

- `maxFileSize` now accepts a human string (`"100mb"`, `"5gb"`) as well as a
  number of bytes — previously a string emitted invalid TypeScript.
- Presign-only no longer emits a stray `FILES_DB` `[[d1_databases]]` block (with
  an invalid `database_id = "local-files"`) into `wrangler.toml`, which would
  have made `wrangler deploy` reject the worker.

