Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

Notable changes to Rekey, covering the self-hosted stack as well as the
`@rekey.dev/*` SDK packages. The packages share one version and release together
with the API, panel and portal.

## 2.4.0

The hosted portal becomes your customers' account page: they can create an
account, sign in by any method the application allows, set up two-factor,
manage their profile, licences, devices and organizations, approve a desktop
app, and do it on your own domain. `mfa: 'required'` is now enforced on every
session. One migration, `20261110132835_portal_domain_verification`: run
`prisma migrate deploy`, then deploy the API, panel and portal together,
because the portal config response changed shape, and upgrade the
`@rekey.dev/*` packages with them. The upgrade, step by step with commands
and checks, is in [docs/migrating-to-2.4.md](docs/migrating-to-2.4.md).

### Before you upgrade

- **`mfa: 'required'` is enforced on the session, not only at sign-in.** A
  user with no confirmed authenticator, passkey-only users included, gets
  `403 MFA_ENROLLMENT_REQUIRED` on ordinary user-session routes, and so do
  sessions issued before the upgrade. What stays open is what they need to
  enrol or sign out: `GET /auth/mfa/status`, `POST /auth/mfa/setup`,
  `POST /auth/mfa/setup-confirm`, `POST /auth/send-verification`,
  `POST /auth/sign-out-everywhere`, and `GET /auth/me` and `GET /users/me`
  without `include`. Impersonation sessions are not held back. Send such a
  user through setup before anything else.
- **Device-bound applications need `INTERNAL_CALLER_SECRET` on both the API
  and the portal** for their customers to use the portal. Without it they
  still get `DEVICE_FINGERPRINT_REQUIRED`. The portal's `PORTAL_BASE_URL` must
  have the same origin as the API's `PUBLIC_PORTAL_URL`.
- **Portal-enabled applications use the portal as their authorization page.**
  An application with `oidcEnabled` or `mcpEnabled`, the portal on and no
  `hostedAuthorizeUrl` of its own now sends the browser to the portal instead
  of the built-in page.
- **Organization invitations are emailed** to the invited address, on invite
  and on resend, and the response gains `emailSent`. If your app mailed the
  link itself, stop, or the invitee gets two.
- **`GET /billing/plans` gains `defaultPlanSlug`**, the free plan
  `POST /billing/subscribe` activates, or `null`. `billing.getPlans` in
  `@rekey.dev/node` and `getPlans` in `@rekey.dev/react` now return
  `PublicPlanPage` instead of `Paged<PublicPlanDto>`.
- **Deleting an account with no password and no two-factor** from a browser
  answers `401 EMAIL_CODE_REQUIRED` instead of `400 STEP_UP_UNAVAILABLE`.
  Ask for an emailed code and send it as `emailCode` (below).
- **A publishable key on authorization preview or grant** gets
  `403 SESSION_HANDOFF_FORBIDDEN` instead of a 401.
- **The new `DELETE` routes under `/users/me/licenses` need `billing:write`**
  on a secret key; reads still need `billing:read`. The `license.deactivated`
  webhook may carry `releasedBy: "end_user"`.
- **Portal brand colours must be 3 or 6 digit hex.** `rgb()`, `hsl()`, named
  colours, 8-digit hex and `transparent` fall back to the default, as does a
  colour that would leave the text unreadable. Checkout and email were already
  hex-only.
- **Password reset emails sent before the upgrade** land on a page that asks
  for a new link.
- **Rotating `JWT_SECRET` lapses every verified custom domain** until its TXT
  record is published again, because the record's value is derived from it.
- **The portal's `Referrer-Policy` is `same-origin`**; it was `no-referrer`.

### Hosted portal

- **Sign-in follows the application's methods:** password, an emailed sign-in
  link, both, or a note that sign-in happens in the app. Social sign-in is
  opt-in per provider: register `<PUBLIC_PORTAL_URL>/<slug>/oauth/callback` in
  the provider's console, then tick **Offer in the hosted portal** on the
  provider (`hostedPortal`, off by default).
- **Create an account** while `signupMode` is `public`, followed by a step for
  the application's required profile fields. With sign-up open, an emailed
  sign-in link for a new address creates the account, and the page says so.
- **Email confirmation** with a resend control and its own landing page.
- **Required two-factor setup** (QR code, setup key, backup codes) before the
  dashboard when the application sets `mfa: 'required'`.
- **Security page:** change a password, or set a first one with an emailed
  code; turn two-factor on and off and replace backup codes; sessions with
  "This session" marked, sign out one or everywhere; remove a linked provider.
- **Profile page:** profile answers, change email (a link to the new address,
  which ends every session once confirmed), and delete the account.
- **Licenses and usage:** plan benefits, usage meters, credits, licence keys,
  each licence's machines and freeing a seat, and **Start** on the
  application's free default plan.
- **Organization page:** list and switch, members and roles, invite, pending
  invitations with revoke and resend, leave, create, and accept an invitation
  from its link.
- **Devices page** for device-bound applications: list, remove, and what the
  device limit means. A desktop app at its limit can send the customer to
  `/<slug>/login?return=devices`. On such an application the portal signs the
  customer in with a portal session: it holds no device and no `max_devices`
  slot, the API honours it only on requests that carry
  `INTERNAL_CALLER_SECRET`, so it is useless outside the portal, and it cannot
  approve another app.
- **App authorization:** sign-in and consent for the authorization flow, then
  a page that sends the user back to the app.
- **Custom domains.** Enter a hostname in Panel, Application, Billing, Portal,
  point a CNAME at the portal, publish the TXT record shown
  (`_rekey-portal.<hostname>`), route the host to the portal over HTTPS, then
  verify. Only a verified exact match is served, and verifying moves emailed
  links, the authorization page and checkout returns to the domain at once,
  so route it first. The API re-checks every six hours and lapses a domain
  after three misses in a row (`app.portal_domain_lapsed`). Self-hosted with
  `docker-compose.prod.yml`, add the domains to `PORTAL_EXTRA_HOST_RULES`.
- **Navigation:** a header with Overview, Devices, Security, Profile and
  Organization where they apply, and terms and privacy links. A signed-out
  visit to a plan returns to that plan after sign-in.
- **Dark mode** when the operator set no brand colours, 44px touch targets and
  solid focus outlines. Dates and amounts are written in the visitor's locale.
- **Emailed tokens are spent by a button.** Sign-in, confirmation, reset,
  email change and invitation links move their token into a short httpOnly
  cookie and open a token-free page, so a mail scanner cannot use one up.
- The plan page states the renewal and cancel terms, the dashboard refreshes
  after checkout until the subscription is active, and **Switch** is now
  **Subscribe**: checkout starts a second subscription and cancels nothing.

### API and SDK

- **Auth:** `POST /auth/sign-up` takes `verifyUrl`. `GET /auth/sessions` marks
  the caller's own session with `current`. With the portal on and no App URL
  to link to (no `appUrl`, no redirect URL and no `DEFAULT_APP_URL`),
  verification, reset, sign-in, email change and invitation emails link to
  the portal, and `requireEmailVerification` can be turned on without
  `appUrl`.
- **Current user:** `GET /auth/me` and `GET /users/me` return `hasPassword`,
  whether the user has a password (never the hash), so a client can offer
  "change password" or "create a password". SDK: `CurrentUserDto` in
  `@rekey.dev/shared-types`, returned by `getCurrentUser` (react).
- **MFA:** `POST /auth/mfa/backup-codes` replaces the backup codes, with a
  current authenticator code. SDK: `auth.replaceBackupCodes` (node),
  `replaceBackupCodes` (react). Passkey sign-in now reports
  `mfaEnrollmentRequired` too.
- **Emailed codes and first password:** `POST /auth/email-code` emails a
  six-digit code for `set_password` or `delete_account`;
  `POST /auth/set-password` sets a first password with it, and
  `DELETE /users/me` takes it as `emailCode`. SDK: `auth.requestEmailCode`,
  `auth.setPassword` (node), `requestEmailCode`, `setPassword` and
  `emailCode` on `deleteAccount` (react).
- **Email change:** `POST /users/me/email` sends a link to the new address,
  `POST /auth/email-change/confirm` spends it. SDK:
  `auth.requestEmailChange`, `auth.confirmEmailChange` (node),
  `requestEmailChange` (react).
- **Organizations and invitations:**
  `GET /users/me/organizations/:id/invitations` lists pending invitations,
  `POST /users/me/organizations/:id/invitations/:invId/resend` emails a fresh
  link, `POST /auth/organizations/invitation-preview` says what an invitation
  offers before it is accepted, and the members list returns `baseRole`. SDK:
  `organizations.listInvitations`, `organizations.resendInvitation`,
  `organizations.previewInvitation` (node), `listOrganizationInvitations`,
  `resendOrganizationInvitation`, `previewOrganizationInvitation` (react). A
  new built-in `organization_invitation` email template.
- **Licenses:** `GET /users/me/licenses/:licenseId/activations` lists a
  licence's machines, `DELETE /users/me/licenses/:licenseId/activations/:activationId`
  frees a seat. SDK: `licenses.listMyActivations`,
  `licenses.releaseMyActivation` (node), `listLicenseActivations`,
  `releaseLicenseActivation` (react).
- **OAuth providers:** end-user `start` and `callback` take `redirectUri` for
  the application's own portal page, and `GET /auth/oauth/providers` reports
  `hostedPortal`.
- **Portal config and domains:** `GET /portal/config/:slug` returns `auth`
  (`password`, `magicLink`, `signup`, `emailVerificationRequired`,
  `mfaRequired`, `devices`, `organizations`); `GET /portal/config?host=` finds
  an application by its verified domain;
  `POST /tenant/applications/:id/portal/domain/verify` checks the TXT record,
  which `GET /tenant/applications/:id` returns as `portalDomainTxtRecord`.
- **Authorization:** preview and grant accept the portal's credential, and
  `POST /mcp/:slug/oauth/authorize/deny` lets the portal refuse a request.
- **New error codes:** `MFA_ENROLLMENT_REQUIRED`, `MFA_NOT_ENROLLED`,
  `PASSWORD_ALREADY_SET`, `EMAIL_CODE_INVALID`, `EMAIL_CODE_REQUIRED`,
  `EMAIL_CODE_NOT_ACCEPTED`, `EMAIL_CODE_NOT_SENT`,
  `EMAIL_CHANGE_SAME_ADDRESS`, `EMAIL_CHANGE_DOMAIN_NOT_ALLOWED`,
  `EMAIL_CHANGE_URL_REQUIRED`, `EMAIL_CHANGE_NOT_SENT`,
  `EMAIL_CHANGE_TOKEN_INVALID`, `EMAIL_CHANGE_PROOF_REQUIRED`,
  `OAUTH_REDIRECT_URI_NOT_ALLOWED`, `OAUTH_PROVIDER_NOT_IN_PORTAL`,
  `ORGANIZATION_INVITATION_UNAVAILABLE`, `ORGANIZATION_INVITATION_NOT_PENDING`,
  `AUTHORIZATION_REQUEST_UNKNOWN`, `AUTHORIZATION_NEEDS_DEVICE_SESSION`, and
  `PORTAL_DOMAIN_INVALID`, `PORTAL_DOMAIN_RESERVED`, `PORTAL_DOMAIN_NOT_SET`,
  `PORTAL_DOMAIN_NOT_VERIFIED`, `PORTAL_DOMAIN_DNS_UNAVAILABLE` and
  `PORTAL_DOMAIN_CHANGED`. See docs/errors.md.

### Fixed

- The portal sent no `Origin` on its publishable-key calls, so every
  application with an origin allowlist got `403 ORIGIN_NOT_ALLOWED` on
  sign-in, two-factor, refresh and password reset.
- An end user's authenticator entry was named "Rekey". It now carries the
  portal display name, else the application name.
- The organization members list dropped `baseRole` on the wire.
- A portal form posted before the page hydrated answered 500.
- On an application without password sign-in, the portal's reset pages
  offered a reset that could never work; they now send the customer to sign
  in.
- A real person's portal sign-up could be dropped as a bot when autofill
  filled the hidden bot-trap field.

### Security

- A failed sign-in's typed address and the reset token no longer appear in
  portal URLs.
- On a custom domain the portal believes `X-Forwarded-Host` only with the
  proxy secret, so a request around the proxy cannot pick an application.
- The operator's logo is loaded with no referrer.

## 2.3.1

Operator access tokens reach billing and analytics, the panel's navigation and
webhook pages are reworked, sign-in links ask before they sign you in, and
secrets are encrypted at rest in every environment. No migration: deploy the
API, panel and portal together and upgrade the `@rekey.dev/*` packages with
them.

### Before you upgrade

- **`ENCRYPTION_KEY` is required outside the test suite.** The API refuses to
  boot without it in development too, not only in production. Docker and
  compose deployments already required it. A source checkout run with
  `pnpm dev`, or a host that never sets `NODE_ENV`, must add one:
  `openssl rand -hex 32`. Credentials such a deployment stored without a key
  stay readable; save them again to encrypt them.
- **The self-host compose file requires `REDIS_PASSWORD`.** An unset password
  used to start Redis with no authentication at all.
- **`NODE_ENV=test` refuses to boot outside the test suite.** Test mode lifts
  the sign-in rate limits and returns reset tokens in responses.
- **Calls an operator token could never make are removed.** On 2.3.0 they
  always answered `401 TENANT_SESSION_INVALID`; issue a refund and change the
  unavailable-currency default in the panel instead.
  - `@rekey.dev/node`: `operator.payments.refunds.create`,
    `operator.billing.updateConfig` and the `OperatorBillingConfigUpdate` type.
  - `@rekey.dev/cli`: `rekey refunds create` and
    `rekey billing unavailable-currency set`.
  - `@rekey.dev/mcp`: the `create_refund` tool. The operator MCP server keeps
    its own `create_refund` for admin connections.
- **Token scopes are named correctly.** Mint operator tokens with `read`, and
  add `applications:write` to change plans, plan prices, coupons or the
  reporting currency. `billing:read` and `billing:write` were never token
  scopes.

### Added

- **Operator tokens reach billing and analytics.** `RekeyOperator`, the
  operator CLI commands and the stdio MCP billing tools now work with
  `Authorization: Bearer rp_op_…`: plans, plan prices, coupons, payments and
  their refunds, the billing log, reconciliation, price fallbacks, the
  reporting currency, the routing preview, billing stats and user insights.
  Refunds, provider credentials, cancellations and billing settings stay
  session-only and answer `403 OPERATOR_TOKEN_NOT_ACCEPTED` to a token.
- **Operator MCP read tools:** `list_end_users`, `get_end_user_credits`,
  `list_licenses`, `list_webhook_deliveries`, `list_subscription_imports`,
  `list_unapplied_payments`, `get_dunning_status`, `get_organization` and
  `list_member_grants`. `invite_member` takes `appGrants`, which needs admin
  access.
- **Find an end user from the command palette.** Inside an Application, type
  an email to jump to that end user.
- **Panel:** Mark complete and Record skip on an end user whose onboarding is
  pending; an owner-only Erase on a list member that removes the contact from
  every list; organization member roles confirm what each role can do.

### Changed

- **Webhook deliveries** filter by status and page through every delivery,
  and show the payload and response. Retry all failed works through every
  failed delivery in batches of 100 and stops cleanly at the rate limit.
  Incoming provider events expand to the full error.
- **Account** is one page with Security, Passkeys, API tokens, AI assistants
  (MCP) and My requests tabs, opened from the account menu.
- **Email** has its own tabs in the Application nav: Settings, Templates,
  Custom templates, Delivery and Suppressions.
- **Billing** tabs list what happened first (Overview, Payments, Unapplied,
  Dunning, Billing log), then what a buyer is offered (Setup, Plans, Coupons,
  Meters, Licenses, Portal). Imports is reached from Setup.
- Users, Overview is now **Users, Insights**, and Billing, Setup, Settings is
  now **Billing, Setup, Defaults**.
- Confirm buttons show that they are working and cannot be pressed twice;
  every confirm dialog names what it does. Disabling the hosted portal asks
  first.

### Fixed

- The panel Docker image ships its `public/` directory, so a self-hosted panel
  shows its logo and brand fonts.
- An organization owner can no longer leave by removing their own membership;
  the leave rule applies to both paths.

### Security

- A sign-in link opens a Continue signing in page and is spent only when you
  confirm, so a mail scanner or a forwarded link signs nothing in.
- A sign-in handed over from another site waits on a confirm page that names
  the account, and says when it would replace the current session.
- An operator MCP write tool checks the caller's grant on the Application, so
  an `APP_VIEWER` cannot write through it.
- PayPal refusals are logged without the payer's name, email or address.
- The API warns at boot when `ADMIN_IP_ALLOWLIST` is set and `TRUSTED_PROXIES`
  is a hop count, which lets a direct caller spoof the allowlisted address. The
  super-admin key comparison no longer reveals the key's length.

## 2.3.0

Lifecycle email, cancellation reasons, self-service account deletion and team
invites under invite-only sign-up, plus money-path, privacy and session fixes.
Four migrations, all additive (`20261023202912_email_topic_opt_outs`,
`20261023220638_subscription_cancel_reason`, `20261024043609_scheduled_email_sends`,
`20261024094131_invitation_app_grants`):
run `prisma migrate deploy`, then deploy the API, panel and portal together and
upgrade the `@rekey.dev/*` packages with them.

### Before you upgrade

- **One-click unsubscribe leaves a topic, not every notification email.** The
  link in a notification email sent by 2.3 unsubscribes from that template's
  topic; the confirm page offers a second button to leave every notification
  email. Links in emails sent before 2.3 keep their old meaning.
- **One-time purchases rank after recurring plans.** `GET /billing/subscription`
  returns a recurring paid plan before a credit pack or perpetual licence, and
  an end-user cancel of a one-time purchase answers `409 SUBSCRIPTION_NOT_CANCELABLE`.

### Added

- **Notification topics and email preferences.** Custom templates take an
  optional `topic` (default: the key). `GET` and `PATCH
  /api/v1/users/:id/email-preferences` read and change a user's topics, and the
  `email.unsubscribed` webhook reports each change.
- **Send by end-user id, dry run, read a send back.** `POST /api/v1/email/send`
  takes `to: { endUserId }` (refusing unknown, erased, banned and unverified
  users) and `dryRun: true`; `GET /api/v1/email/sends/:id` and
  `?idempotencyKey=` read the outcome with a secret key. The operator email log
  filters by template, recipient and time.
- **Scheduled sends with cancel.** `sendAt` (up to 30 days ahead) records a
  scheduled send that runs every check again when it fires;
  `DELETE /api/v1/email/sends/:id` cancels it. Caps met at fire time are waited
  out. SDK: `email.send({ sendAt })`, `email.cancelSend(id)`, `email.dryRun`,
  `email.getSend`.
- **Cancellation reasons.** `POST /billing/subscription/cancel` takes `reason`
  (`CANCEL_REASONS`) and `feedback`; they show in the panel, are asked in the
  hosted portal's cancel dialog, and travel on subscription webhooks as
  `data.subscription.cancellation`.
- **Self-service account deletion.** `DELETE /api/v1/users/me` erases the
  caller's account, with step-up from a browser, refused while a subscription
  still bills or from an impersonation session. Each Application chooses
  `authConfig.accountDeletion`: `self_service` (default), `server_only` or `off`.
  SDK: `auth.deleteCurrentUser` (node), `deleteAccount` (react).
- **Team invites under invite-only sign-up**, a simpler Team page, and a
  per-Application setting to keep device release with operators
  (`authConfig.deviceRelease`).
- **Choose a member's apps when you invite them.** The invite dialog takes the
  per-Application grants for a MEMBER, and they apply when the invitation is
  accepted.
- **Panel sidebar** with the running Rekey version beside the logo.

### Fixed

- A resubscribe survives a late deletion of the old provider subscription; a
  Stripe renewal paid before the period update still refills credits; a late
  invoice never moves the period end back; a refused checkout releases its
  coupon slot; a retried trial completion is not announced twice.
- Erasure removes the person's address from organization invitations and the
  webhook deliveries about them, and deletes their scheduled sends and topic
  opt-outs.
- The billing outbox drain reads the database clock, so an event queued a
  moment ago is never skipped.
- Subscription period credits are labelled "Subscription credits" in the ledger.

### Security

- Starting a passkey sign-in no longer reveals whether an address has an account.
- The panel refreshes an operator token inside a Route Handler only when the
  request may rotate it, and the admin login runs the malformed-Origin guard.
- The API refuses a non-hex `ENCRYPTION_KEY` at boot.
- nodemailer 10.2.1 in the API, up from 6.10.1, for sixteen advisories
  including five high (GHSA-rcmh-qjqh-p98v, GHSA-p6gq-j5cr-w38f,
  GHSA-2x7j-588g-ccc2, GHSA-v53p-9fqp-m79j, GHSA-r7g4-qg5f-qqm2). It only
  affects operators who send through their own SMTP server.

## 2.2.1

A security release on 2.2.0 that also ships everything in 2.2.1-rc.1 below.
No migration and no breaking change. **Every self-hosted deployment should
upgrade:** rebuild and redeploy the API, panel and portal images, and the admin
app if you run it.

### Security

- **Next.js 15.5.28 in the panel, portal and admin app.** 15.5.18 had two
  critical advisories in the image optimizer (`/_next/image`, on by default):
  remote code execution through crafted AVIF or SVG input
  (GHSA-2xp9-vwfh-vxw4, GHSA-2464-fhr4-w9xw), plus a Windows-only code
  execution flaw (GHSA-p293-qw3h-jr36), Server Actions denial of service
  (GHSA-m99w-x7hq-7vfj, GHSA-p3m5-59mp-jv5r) and server-side request forgery
  (GHSA-p9j2-gv94-2wf4, GHSA-89xv-2m56-2m9x).
- **sharp 0.35.5**, the image library behind the optimizer, for
  GHSA-f88m-g3jw-g9cj, GHSA-rgj7-g3m4-5g8c and GHSA-wq5f-xc86-pv6w.
- **Fastify 5.12.7 in the API**, for request body replacement
  (GHSA-667r-xxjv-c9mm), a not-found handler reachable through malformed URLs
  (GHSA-p68q-wchp-6fh7) and two validation bypasses (GHSA-hwr6-493r-vm6h,
  GHSA-9q9j-q6p8-xq58). Its router and proxy-address dependencies move to
  find-my-way 9.10 and proxy-addr 2.0.8.
- **Node.js 24.22.1** in every Docker image, the current Node 24 LTS security
  release.
- Patched transitive dependencies of the API and the published packages:
  undici 7.30, `@fastify/swagger-ui` 6 (with `@fastify/static` 10),
  `@modelcontextprotocol/sdk` 1.32 in `@rekey.dev/mcp`, fast-uri, form-data,
  axios, follow-redirects, ip-address, hono, qs, nanoid, js-yaml, devalue,
  source-map-js, svgo, brace-expansion and smol-toml.

## 2.2.1-rc.1

The first release candidate for 2.2.1, a patch release on 2.2.0. No migration and no breaking change: deploy the API
and panel together and upgrade the `@rekey.dev/*` packages with them.

### Added

- **Operator personal access tokens reach onboarding questions and lists.**
  The profile-field routes (`/api/v1/tenant/applications/:id/profile-schema`,
  an end user's profile answers, onboarding complete and skip) and the list
  routes (create, update, archive, restore, members, submissions, unsubscribe)
  now accept `Authorization: Bearer rp_op_…` as well as a panel session. A
  token needs the route's own scope in its own scopes: `read` for reads,
  `applications:write` for writes, even when the operator who minted it is an
  owner. A list's CSV export stays session-only.
- **Operator MCP tools for the same:** `get_profile_schema`,
  `set_profile_schema`, `update_end_user_profile`, `complete_onboarding`,
  `skip_onboarding`, `create_contact_list`, `update_contact_list`,
  `archive_contact_list`, `restore_contact_list`, `list_list_members`,
  `list_list_submissions` and `unsubscribe_list_member`. The write tools need
  write access and the OWNER or ADMIN workspace role.

### Changed

- `list_list_members` and `list_list_submissions` return email addresses, so
  over MCP they need the OWNER or ADMIN workspace role, whatever a member's
  grant on the Application. In the panel nothing changes.
- An MCP tool error now carries the API error's `details`, so an agent sees
  which field or option a refusal is about.

## 2.2.0

**Upgrading from 2.1, or from any 2.2 release candidate: follow
[docs/migrating-to-2.2.md](docs/migrating-to-2.2.md).** It is one guide from
2.1.0 to 2.2.0, with what to check before, the order to upgrade in, and a
before and after table for every changed call.

The stable release of the 2.2 line. It is 2.2.0-rc.7 plus the fixes from a
security review of the API (three to sign-up, sign-in and auth email, one to
what an operator MCP token with write access may do, and two to how the API
behaves toward Redis and toward webhook URLs), and it completes the billing
money model: the columns the price rows replaced are dropped in this release.
Each changes something your users, your forms, your agents or your database
can see. Deploy the API, panel and portal together, and upgrade the
`@rekey.dev/*` packages with them.

**Before you upgrade:** take a database backup, run the preflight queries in
`docs/upgrades/billing-money-model.preflight.sql`, and, if you create users
from your server, read the first two items under Changed and decide which of
those users your server has already verified.

**Run one version of the API at a time, with clocks in sync.** Ending every
session of an account writes a timestamp from the API instance that did it,
and tokens that name no session (MCP access tokens, MFA challenges,
authorization codes) are compared to it by their own issue time, taken on the
instance that minted them. Instances whose clocks differ by more than a few
milliseconds can let such a token minted just before the revocation outlive
it, until it expires (an hour at most for an MCP access token; its refresh
token is revoked regardless of clocks). Keep NTP running on every instance.

### Breaking changes

These are on top of the breaking changes of the release candidates, which the
guide lists in one place.

- **There is no rollback after the migrations run.** `plans.amount`,
  `plans.currency`, `plans.price_per_unit_cents`, `coupons.amount_off`,
  `coupons.currency` and the `currency` key of `billingConfig` are dropped,
  and no earlier version, 2.2.0-rc.7 included, serves plans or coupons
  without them. To go back, restore a backup taken before the upgrade and
  start the previous images against it.
- **Replace every API instance together.** An instance of an earlier version
  still serving after the migrations answers 500 on every request that reads
  a plan, a coupon or an operator. It never writes a wrong value. The table
  of what fails after which migration is in
  [DEPLOY.md](DEPLOY.md#replace-every-api-instance-together).
- **The upgrade stops on a row it cannot migrate, and guesses nothing.**
  A plan or a fixed coupon whose currency is not a three-letter code, a
  subscription on such a plan, a plan with a negative amount, a percentage
  coupon with a rate outside 0 to 10000, and a `billingConfig.currency` that
  is not three letters each stop `price_and_coupon_column_drop_checks` with the table,
  the count and what to do. So does a row where an old column and its
  replacement disagree (a coupon amount, a plan's archived default price, a
  subscription's currency): the migration does not pick one. That migration
  changes nothing when it stops; the migrations before it stay applied, and
  after correcting the rows `prisma migrate resolve --rolled-back <name>`
  and a redeploy carry on from there. Part A of the preflight finds what can
  be found on 2.1.0 before you deploy.
- **`pricePerUnitCents` is kept in the plan's metadata.** The column is
  dropped, and the figure an operator entered moves, as stored, to
  `metadata.pricePerUnitCents` on the plan. Nothing charges it and nothing
  reads it. An operator sees it in the plan's `metadata`; the public plan
  list carries no metadata.
- **The database enforces what the API already wrote.** A subscription's
  `plan_price_id`, `currency` and `unit_amount` are all set or all null; the
  price row is one of the subscription's own plan and cannot be deleted while
  a subscription names it; a percentage coupon has `basis_points` and a fixed
  one has none; a free plan has no default price; `currencies` on
  `billing_credentials` and `coupons` is never NULL. That a subscription's
  `currency` is its price row's currency is checked once by the upgrade and
  kept by the API afterwards; no constraint holds it. This matters only if you
  write to the database yourself.
- **Reports that read the dropped columns stop working.** Read a plan's price
  from the `plan_prices` row `plans.default_price_id` names, a fixed coupon's
  amount from `coupon_amounts`, and a percentage coupon's rate from
  `coupons.basis_points`.
- **Three unused lockout columns are dropped too:** `failed_sign_in_attempts`
  and `locked_until` on `tenant_users`, and `failed_attempts` and
  `locked_until` on `mfa_credentials` and `tenant_mfa_credentials`. Nothing
  has written them since lockout moved to Redis.
- **No operator is confirmed by the upgrade.** Every operator who signed up
  with a password confirms with the emailed link, unless they had already
  signed in by emailed link, Google or GitHub. See "Operators confirm their
  email address" below for what waits on it.
- **A token that names no session, minted before the upgrade, is refused.**
  MCP access tokens (end-user and operator) now have to carry a millisecond
  issue time, and an MFA challenge the account state it was issued against.
  An MCP client gets one 401 and refreshes; its refresh token and its grant
  are untouched. A sign-in that was waiting at the second-factor step when
  you upgraded answers 401 `MFA_CHALLENGE_INVALID`: sign in again.
- **Fields that were optional "for an older deployment" are required, and the
  API sends them on every route.** On `ApplicationDto`: `environment`,
  `supportEmail`, `reportingTimezone`, `promotedAt`, `disabledAt` and
  `disabledReason` (null where there is no value). `GET /api/v1/me` now sends
  the last four; the operator and super-admin Application routes now send
  `supportEmail` at the top level, which they never did; and
  `GET /api/v1/tenant/operator/applications` sends `billingConfig` complete.
  On `AuthResultDto`: `deviceId` and `isNewUser`. On the end-user export:
  `endUser.bannedAt`, `profile`, `onboardingCompletedAt`,
  `onboardingSkippedAt`, `createdVia`, and `supportCases` and `contacts`.
  A fixture or mock of one of these types needs the fields.
- **The panel needs the API of its own release.** It no longer reads a
  response from an earlier API: it does not look up actor emails itself, does
  not hide the Users tab or the reporting timezone when a field is missing,
  and does not fall back to a build-time portal URL. Deploy the API first, as
  before. The panel build argument `NEXT_PUBLIC_PORTAL_URL` is removed: the
  Portal page takes the origin from the API's `PUBLIC_PORTAL_URL` only.
- **`PUT .../email-credentials` requires `provider`.** A body without it was
  read as Resend. Send `"provider": "resend"` or `"provider": "smtp"`.
- **The `/end-user-roles` operator routes are removed.** They were a hidden
  alias of `/api/v1/tenant/applications/:id/application-roles`; call that.
- **`PLAN_CURRENCY_INVALID` is gone.** A plan stored with a malformed currency
  cannot exist after the upgrade, so nothing answers it. Remove it from any
  list of codes you switch on.

**The 2.2.0-rc.7 `@rekey.dev/*` packages keep working against a 2.2.0 API:**
no request or response they use changed between the two. Packages from
2.2.0-rc.6 or earlier, and from 2.1, are refused on the plan, coupon and
stats calls the guide lists. Upgrade them with the API.

### Changed

- **An unverified account belongs to whoever proves the mailbox.** Sign-up
  asks for no proof of the address, so a password account could be registered
  for someone else's email and kept after the real owner signed in. Now the
  first magic-link sign-in, social sign-in (when the provider verified the
  same address) or completed password reset on an account with
  `emailVerified: false` marks it verified and removes everything set before
  it: the password (a reset sets the new one), every session, passkeys, a
  second factor, other linked providers, pending links and device bindings.
  The user id, role, metadata, profile, subscriptions, credits, licences and
  organization memberships stay. **What your users see:** someone who signed
  up with a password, never clicked the confirmation link, and later uses a
  magic link or a social button loses that password, is signed out elsewhere,
  and gets the new `account_claimed` email telling them to set a new one. A
  user who confirmed their address is not affected, and the confirmation link
  itself still keeps the password. You get the security event
  `auth.unverified_account_claimed` and a `user.updated` webhook with
  `changed: ["emailVerified", "credentials"]`.
- **Users your server creates are unverified unless you say otherwise.** The
  rule above keys on `emailVerified`, not on who created the row.
  `POST /api/v1/auth/sign-up` now takes `emailVerified: true` from a secret
  key (a publishable key is refused with `EMAIL_VERIFIED_REQUIRES_SECRET_KEY`),
  and `POST /api/v1/users/import` already takes it per user. Send it for
  every address you have verified yourself. An imported user without it loses
  the imported password and linked identities at their first magic link,
  social sign-in or reset.
- **Passkeys, MFA and provider linking need a verified address.**
  `/auth/passkey/register/*`, `/auth/mfa/setup`, `/auth/mfa/setup-confirm` and
  `/auth/oauth/:provider/link/*` answer 403 `EMAIL_NOT_VERIFIED` for a user
  whose address is unverified, whatever `requireEmailVerification` is set to.
  If your app offers passkey or MFA enrolment straight after sign-up, verify
  the address first.
- **Social sign-in now marks the address verified** when it links to an
  existing account by a provider-verified email. It used to leave
  `emailVerified: false`. So does a completed password reset.
- **Operators confirm their email address, and until they do they cannot
  create lasting access.** Operator sign-up now mails a confirmation link
  (where the deployment can send mail), the panel shows a notice with a "Send
  confirmation email" button on every page until the address is confirmed,
  and `POST /api/v1/tenant/auth/resend-verification` and
  `POST /api/v1/tenant/auth/verify-email` do the same over the API.
  **Confirming with that link keeps everything: the password, sessions,
  passkeys, personal access tokens and MCP grants.** It confirms the address
  and changes nothing else. Because it keeps the password, the link works
  only while signed in to the account it was mailed for:
  `verify-email` needs that account's session (401 `TENANT_SESSION_MISSING`
  without one, 403 `EMAIL_VERIFICATION_WRONG_ACCOUNT` with another
  account's), and the panel page sends a signed-out visitor to sign in and
  back. Someone who receives the email for an account they did not create
  cannot sign in to it; the email and the page point them at "Email me a
  sign-in link", which confirms the address and removes the password instead.
  A newer confirmation email replaces the earlier ones, and after a
  confirmation the address is mailed a short notice
  (`operator_email_confirmed`).
  Operators had no way to confirm an address before, so **every operator who
  signed up with a password is unconfirmed after the upgrade** unless they
  had already signed in by emailed link, Google or GitHub. The upgrade
  confirms nobody: no migration marks an operator verified. Until
  they confirm, these answer 403 `EMAIL_NOT_VERIFIED`: inviting someone,
  changing a member's role, scopes or Application grants, creating another
  workspace, minting an Application API key, rotating the publishable key,
  creating or changing a webhook endpoint or rotating its secret, storing or
  changing billing provider credentials or registering a provider webhook,
  configuring a sign-in provider, changing `appUrl` or `redirectUrls` in
  `authConfig`, changing the IP allowlist and CORS origins, setting an
  Application's email credentials, sender or a built-in email template,
  adding a passkey, turning on two-factor authentication, creating a personal
  access token and approving an MCP client. The same holds for the operator
  MCP tools `invite_member`, `change_member_role`, `mint_api_key`,
  `create_webhook_endpoint`, `update_webhook_endpoint`,
  `configure_billing_provider` and `update_auth_config` (when it changes
  `appUrl` or `redirectUrls`). Creating Applications, plans, prices and
  coupons, reading anything, revoking and deleting, and accepting an
  invitation into someone else's workspace stay open. Existing personal
  access tokens and MCP grants keep working for reads and ordinary writes;
  **have each operator sign in, press "Send confirmation email" and confirm
  with the link once, and nothing else changes for them.**
  Confirming another way still replaces what was there: a magic link, a
  Google or GitHub sign-in or a completed reset on an unconfirmed operator
  removes the password (a reset sets the new one), sessions, passkeys, second
  factor, personal access tokens, MCP grants and unaccepted invitations that
  account sent, revokes the Application API keys it minted and switches off
  the webhook endpoints it created (for keys and endpoints created from this
  release on, which record who made them). Other workspace members are never
  removed; the `auth.unverified_account_claimed` event and the notice email
  (`operator_account_claimed`) name every other member of each workspace the
  account owns or administers and count its Applications, live API keys,
  webhook endpoints and billing providers, saying how many keys and endpoints
  have no recorded creator and were therefore left in place, so the owner can
  review them; the notice says where to disable an Application they did not
  create. Impersonation sessions the account had open are ended. An operator with two-factor on is
  neither wiped nor confirmed by those sign-ins until the code is given, and a
  reset confirms nothing for them. A deployment with no way for an operator
  to prove a mailbox (no deployment mail, no operator sign-in provider, no ID
  Token handoff) refuses none of this: see docs/tenant-auth.md.
  `GET /api/v1/tenant/auth/me` gains `emailConfirmation: { required, byEmail }`.
  An operator provisioned by Rekey Cloud is confirmed by the sign-in handoff
  and is not affected.
- **A sign-in that was in flight while every session was ended gets no
  session.** A claim, a password change or reset, and sign-out everywhere now
  hold the account while they revoke, and a sign-in or refresh that checked
  its credential just before one of them is refused when it tries to write
  its session: a password answers 401 `INVALID_CREDENTIALS`, a refresh token
  401 `REFRESH_TOKEN_REVOKED`, and a link, provider, passkey or second factor
  the new 401 `CREDENTIAL_SUPERSEDED` ("sign in again"). A refresh that loses
  to such a revocation no longer counts as reuse. MCP access tokens carry a
  millisecond issue time (`iat_ms`) and one minted in the same second as a
  claim, before it, is refused. Clients need no change beyond treating
  `CREDENTIAL_SUPERSEDED` like any failed sign-in.
  The same holds one step earlier, for what a sign-in leaves behind to be
  redeemed later. An OAuth authorization code (end-user MCP and OIDC, and
  operator MCP) is written only while the account is as it was when the
  sign-in, consent or session behind it was checked, and one from before a
  revocation of every session is refused at the token endpoint
  (`invalid_grant`); the hosted authorize form shows "Your sign-in expired"
  instead of redirecting with a code. An MFA challenge is tied to the account
  as it was when the first factor verified, and answers 401
  `MFA_CHALLENGE_INVALID` if every session was ended since (a ban ends
  every session too, so a challenge from before a ban is not revived by
  lifting it).
- **Replaying a refresh token from before a revocation of every session no
  longer signs anyone out.** A rotated refresh token presented again is
  normally treated as theft and ends every session. A token created before
  the account's last claim, password change or reset, sign-out everywhere or
  earlier reuse now answers 401 `REFRESH_TOKEN_REVOKED` and revokes nothing:
  whatever it belonged to was already over, and treating it as theft let
  whoever kept one sign the account's owner out, repeatedly. Reuse of a token
  from the current sessions still answers `REFRESH_TOKEN_REUSED` and ends
  them all. The same rule applies to operator sessions and operator MCP
  refresh tokens.
- **A built-in email template test send is restricted.** `POST
  …/email-templates/:eventKey/test-send` needs a confirmed operator address,
  counts against the per-recipient cap (429 `AUTH_EMAIL_RECIPIENT_CAPPED`),
  and, on an Application with no email provider of its own, sends only to the
  calling operator's own address (403 `EMAIL_TEST_RECIPIENT_NOT_ALLOWED`
  otherwise). It used to mail any address through the deployment's shared
  transport. The operator actions that mail an end user a reset or a
  verification link also wait for a confirmed operator address.
- **A claim also cancels organization invitations the account sent and
  replaces its licence keys.** A licence key issued to an account before
  anyone confirmed its address stops working when the account is claimed, and
  its activations are removed; the licence stays. `user.updated` then lists
  `licenseKeys` in `changed`: issue the user a new key. Licences pooled to an
  organization keep their keys.
- **A social sign-in held for a two-factor code links the provider only once
  the code is given**, not before.
- **Auth email that is never sent no longer uses up an address's allowance.**
  A request to `/auth/forgot-password`, `/auth/magic-link/request` or
  `/auth/resend-verification` (and the operator equivalents) that mails
  nobody, because the address has no account or nothing to verify, gives the
  reservation back. A secret key still meets 429 `AUTH_EMAIL_RECIPIENT_CAPPED`
  after the same number of such requests, counted separately, so the 429 does
  not say whether the address has an account.
- **Sign-up no longer says an address is taken when it gives no session
  anyway.** With `authConfig.requireEmailVerification` on, a publishable-key
  sign-up for an address that already has an account gets the same 403
  `EMAIL_NOT_VERIFIED` as a new address, with the same body and headers, and
  the owner is emailed instead (`signup_existing_account`, or
  `signup_finish_setup` with a link to choose a password when the account was
  never verified). **A sign-up form that waited for `EMAIL_ALREADY_EXISTS` to
  say "sign in instead" must show "check your inbox" for
  `EMAIL_NOT_VERIFIED`.** Secret keys still get the 409. With the setting off
  (the default) the 409 is unchanged, because sign-up returns a live session
  there; turn the setting on to close it.
- **Browser sign-ups are limited to 30 an hour per address**, per Application,
  on top of the existing 10 a minute, while `requireEmailVerification` is off.
  The 31st answers 429 `RATE_LIMITED`. Secret keys are not counted.
- **Auth email to one mailbox is capped: 5 an hour and 15 a UTC day per
  Application**, counted by recipient across every caller and IP
  (`AUTH_EMAIL_RECIPIENT_HOURLY_CAP`, `AUTH_EMAIL_RECIPIENT_DAILY_CAP`).
  `a+1@` and `a+2@` are one mailbox. Over the cap, a **secret key** calling
  `/auth/forgot-password`, `/auth/magic-link/request` or
  `/auth/resend-verification` gets the new 429 `AUTH_EMAIL_RECIPIENT_CAPPED`
  with `Retry-After`; so does the signed-in `/auth/send-verification` and an
  operator sending a reset or a verification from the panel. A publishable
  key gets the usual 200 body and nothing is sent. A sign-up over the cap
  still answers 201; its welcome and verification mails are skipped. Security
  notices are not counted.
- **A publishable key no longer waits for the mail** on `/auth/forgot-password`,
  `/auth/magic-link/request` and `/auth/resend-verification`. The response
  comes back before the email is built, so it takes the same time whether or
  not the address has an account. The body is unchanged.
- **Those three routes answer 503 `DEPENDENCY_UNAVAILABLE` when Redis is
  unreachable**, for every address, instead of sending uncounted.
- **`recipientCapKey` treats Gmail dots and `googlemail.com` as one mailbox**,
  which also applies to the per-recipient cap on custom sends
  (`EMAIL_RATE_LIMITED`).
- **Three built-in emails are new** and can be customised like the others:
  `account_claimed`, `signup_existing_account`, `signup_finish_setup`. A
  fourth, `operator_account_claimed`, and the two operator confirmation mails
  (`operator_email_verification`, `operator_email_confirmed`) go from Rekey to
  operators and have no per-Application copy. The
  default `email_verification` copy gains a line telling the reader to confirm
  only if they signed up themselves. A copy you saved earlier is not changed.
- **`user.updated` has new `via` values** (`oauth`, `password_reset`, `mfa`)
  and a new entry in `changed` (`credentials`). A handler that switches on
  either should have a default.
- **`EMAIL_NOT_VERIFIED`'s `fix` text changed**, and the code now has a second
  meaning (no new sign-in method on an unverified account). Branch on the
  code and the route you called, not on the text.
- **Accounts verified by the old code are not changed by this upgrade.**
  Before this release a magic-link sign-in (end users and operators) and an
  operator Google or GitHub sign-in marked an account verified and left its
  password in place. If someone else had registered that address first, their
  password still works on that account. Rekey cannot tell those accounts from
  honest ones afterwards, because sign-in, reset and verification links are
  deleted when they expire, so nothing is wiped automatically. docs/auth.md
  ("Accounts verified before 2.2.0") has a query that lists the accounts worth
  a look and how to end their sessions and passwords.

### Fixed

- **Pre-registration account takeover (end-users and operators).** Signing up
  with a victim's email and a password, then waiting for the victim to sign in
  by magic link, social sign-in or reset, left the attacker's password and
  sessions working on the account the victim now used. Turning on
  `requireEmailVerification` did not prevent it. See the first item under
  Changed.
- **Mail bombing through the auth forms.** One caller on many addresses, or a
  run of `victim+N@` sign-ups, could have an Application send unlimited reset,
  sign-in, verification and welcome mail to one inbox.
- **A sign-in link minted before an address had an account** now also takes
  over an unverified account someone registered for that address in between.
  It used to sign the link's holder in beside the registrant's password.
- **Account enumeration through sign-up** on Applications that require a
  verified address, and a response-time difference between known and unknown
  addresses on the three email-request routes for publishable keys.
### Security

- **Operator MCP: write access can no longer reach admin power.** A
  connection with write access only (an OAuth grant without
  `mcp:operator:admin`, or any personal access token) could invite or promote a
  workspace OWNER, change where sign-in links point, and register a webhook
  endpoint that receives every event. It no longer can.
  - **Breaking:** `update_auth_config`, `create_webhook_endpoint`,
    `update_webhook_endpoint` and `change_member_role` are admin tools. An agent
    that used them with a write-only connection must reconnect and approve
    admin access on the consent page. A personal access token cannot call them
    over MCP at all; use an OAuth connection or the panel.
  - **Breaking:** `invite_member` and `remove_member` act on MEMBERs only with
    write access. `create_organization_role`, `update_organization_role`,
    `delete_organization_role`, `add_organization_member` and
    `set_organization_member_role` act on MEMBER-tier roles only. Anything
    involving an OWNER or ADMIN needs admin access.
  - A refused call answers the new code `OPERATOR_MCP_ADMIN_REQUIRED` in the
    tool result. The table of what each tier can do is in
    [docs/mcp.md](docs/mcp.md#what-write-access-cannot-do).
  - The consent page now lists what read and write access and admin access
    each allow.

- **A stalled Redis no longer hangs requests.** When Redis kept its
  connections open and stopped answering (a paused container, a frozen host),
  requests that touch Redis waited for ever. A Redis command now gets one
  second. Sign-in and the other credential routes answer 503
  `DEPENDENCY_UNAVAILABLE` naming Redis, routes that only use Redis for the
  general rate limit keep serving, and the API recovers by itself when Redis
  answers again. Nothing to configure.

- **Production sends to your URLs only over https.** A plain `http://` URL
  let webhook payloads, with your users' email addresses and ids, travel
  unencrypted. A deployment running with `NODE_ENV=production` now refuses it
  with the new code `URL_NOT_HTTPS`.
  - **Breaking:** creating a webhook endpoint with an `http://` URL, or
    changing one to it, answers 400 `URL_NOT_HTTPS`, over REST, in the panel
    and through the MCP tools.
  - **Breaking:** an endpoint already stored as `http://` receives nothing.
    Each delivery records an error starting `URL_NOT_HTTPS:`, shown on the
    endpoint's page in the panel and in `GET …/deliveries`. Move the receiver
    to https, change the endpoint URL, and retry the deliveries. Nothing is
    rewritten for you.
  - **Breaking:** an OIDC sign-in provider whose `issuerUrl` or discovered
    endpoints are `http://` is refused at save and at sign-in.
  - Self-hosters who deliver to http services on their own private network
    keep doing so with `WEBHOOK_ALLOW_PRIVATE_TARGETS=true`. Development
    deployments are unchanged.
  - `GET /api/v1/tenant/applications/:id/webhooks` returns `httpsRequired`.
  - The operator MCP webhook tools now refuse a private or loopback URL with
    `WEBHOOK_URL_UNSAFE` at create and update, as the REST routes always did.
    See [docs/webhooks.md](docs/webhooks.md#https-only-in-production).

## 2.2.0-rc.7

A release candidate on the 2.2.0 line, and a breaking one. A plan can now be
sold in more than one currency, a buyer's country routes them to a provider
and a price, coupons hold an amount per currency, refunds are recorded one by
one, and every money change is written to a billing log you can read per
Application. To get there the price moved off the plan onto price rows, and
several request and response shapes changed with no aliases kept.

**Upgrading an existing deployment:** follow
[docs/migrating-to-2.2.md](docs/migrating-to-2.2.md), which replaced the page
this release candidate shipped with. Run
`docs/upgrades/billing-money-model.preflight.sql` against your database before
you deploy: the first migration refuses to run when any plan, price,
subscription, payment, checkout or coupon is in HUF, TWD, ISK, UGX or MGA,
because this release changes how those amounts are sent to providers and
nothing rescales stored rows. Deploy the API first, then the panel and portal,
in one sitting. Upgrade the `@rekey.dev/*` packages together with the API: the
rc.6 packages send fields this API refuses.

### Changed

Every item here changes a behaviour an existing 2.2.0-rc.6 deployment or
integration can see.

- **A plan's price is a price row.** Plans carry `price` (the default price,
  resolved for the caller) and `prices[]`. The plan-level `amount`, `currency`
  and `pricePerUnitCents` are refused on create and update with
  `BILLING_FIELD_REMOVED` and are no longer returned. Create a plan with
  `price: { currency, amount }`.
- **The public plan list is an explicit shape.** `GET /api/v1/billing/plans`
  no longer returns `metadata`, `applicationId`, `active`, registration status
  or errors, or timestamps. It previously returned provider price ids and raw
  provider error text to a publishable key.
- **Coupons hold terms per kind.** A percentage coupon takes `basisPoints`; a
  fixed coupon takes `amounts: [{ currency, amountOff }]`. Top-level
  `amountOff` and `currency` are refused. A fixed coupon is refused, at preview
  and at checkout, in a currency it has no amount for
  (`COUPON_CURRENCY_NOT_COVERED`); nothing is converted. A percentage discount
  rounds toward the buyer.
- **A provider's empty currency list means it charges nothing.** It used to
  mean any currency. The upgrade fills every enabled provider's list so none
  is cut off, and a provider saved without a list starts with the currencies
  it usually charges. Codes a provider does not publish are refused on save.
- **`x-country` is refused.** Send the buyer's country in the checkout body
  from your server, or let the SDK send `x-rekey-visitor-country-hint`.
- **Billing stats and super-admin figures are per currency.** The scalar
  `mrrCents`, `mrrCurrency`, `revenueLast30dCents`, `mixedCurrencies` and
  `volumeCents` fields are gone; read the per-currency arrays. Revenue is net
  of refunds and counts verified payments only. MRR counts a subscription at
  its own price and leaves out one that has none.
- **A subscription reports its own price.** `price` is null for a subscription
  with no price of its own; it no longer borrows its plan's. Outbound
  `subscription.*` webhooks carry the subscription's own amount and currency.
- **A charge that matches nothing is recorded, not dropped.** It becomes an
  unverified payment with an unapplied case and grants nothing until an
  operator applies it.
- **A price that something references keeps its amount.** Changing it archives
  the row and adds a new one (`PLAN_PRICE_REFERENCED` on an in-place edit).
- **`billingConfig.currency` is now `billingConfig.reportingCurrency`.**
- **Several response fields that were optional are required**, among them the
  checkout result's `currency`, `unitAmount`, `mode` and `warnings`.
- **CLI:** `rekey plans create` takes the plan's price as its default price
  row; the per-unit price flag is gone.

### Added

- **Prices in more than one currency on one plan**, each with the countries it
  applies to, and a per-Application and per-plan rule for whose word about the
  buyer's country counts (`localPrices`).
- **Routing by country and currency.** A provider declares the currencies and
  countries it serves; checkout picks the provider that can charge the price
  selected. When no enabled provider can charge a local price the buyer is
  charged the default price, is told before paying, and the fallback is
  logged. `onUnavailableCurrency: 'refuse'`, per request or as the
  Application's default, refuses instead.
- **A routing preview**: for a country, plan and coupon, which price, currency
  and provider a buyer would get and why. It calls the functions checkout runs
  and creates nothing.
- **The billing log**: an append-only record per Application of checkouts,
  payments, refunds, subscription and price changes, coupon use and billing
  settings changes, with who made each one. Read it from the panel, the API,
  `@rekey.dev/node`, `rekey billing log` and MCP, as a whole or per
  subscription, payment or end user. A "default price used" report and an
  hourly read-only reconciliation come with it. See
  [docs/billing-log.md](docs/billing-log.md).
- **Refunds as records**: partial refunds, several per payment, and refunds an
  operator starts from the panel or `POST .../payments/:id/refunds`. Outbound
  `payment.refunded`; payments carry `refundedAmount` and `verification`.
- **A reporting currency per Application.** There is no conversion: totals stay
  per currency and this one is listed first.
- **Webhook readiness asks the provider** whether the webhook exists and
  listens for the right events, so going live no longer needs a paid event to
  arrive first. Reconfigure re-registers it after credentials change.
- **Support mode**: an operator opens a case on an end user, adds notes, and
  closes it; outbound `support.opened`, `support.updated` and
  `support.closed`; `support` is available in the `/me` include list.
- **`RekeyOperator`** in `@rekey.dev/node` for operator-token calls (prices,
  coupons, refunds, billing log, stats, routing preview, billing settings),
  and money helpers (`formatMoney`, `validatePriceAmount`) re-exported from
  every package so nothing computes decimals itself.
- **`<PricingTable>`** shows the price resolved for the visitor, a note when a
  local price is unavailable, and a plan that cannot be bought as unavailable.
- **CLI**: `plans prices`, `coupons`, `billing log|preview|reconciliation|
  price-fallbacks|reporting-currency|unavailable-currency`, `refunds`,
  `payments show`, and money checks in `rekey doctor`.
- **MCP**: operator and stdio tools for prices, coupons, refunds, the billing
  log, the routing preview and the reporting currency.
- **Panel**: plan prices, coupons per currency, billing settings, a Billing
  log section, the routing preview, a per-currency overview, and a payment
  page with refunds. A restriction is shown on the control before you submit.
- **Docs**: [docs/currencies.md](docs/currencies.md), the billing log page and
  the migration page. The TypeScript samples in the docs are compiled by a
  test.

### Fixed

- Amounts in ISK and UGX at Stripe, HUF and TWD at PayPal, and MGA at Stripe
  were sent at the wrong scale. Each provider now converts at its own edge.
- The panel showed 100 times the charged amount for fifteen currencies with no
  decimals in display. Every app formats money through one registry.
- A browser naming a provider could select the cheaper of two prices.
- The checkout page could show the default price while charging a local one.
- The coupon preview could accept a code that checkout then refused.
- A renewal at a price other than the subscription's was dropped; it is now
  recorded and flagged.
- The super-admin tenant list ranked only the newest page of tenants by MRR.
- Concurrent saves of billing settings could overwrite one another.
- Erasing an end user left their country in billing records.

### Upgrade notes

1. Run the preflight query and read its output. Rows in HUF, TWD, ISK, UGX or
   MGA must be rescaled or removed by hand before the migration will run.
2. Deploy the API; the migrations run on boot and backfill price rows,
   subscription prices, payment fields, coupon terms and provider currency
   lists. Then deploy the panel and portal.
3. Upgrade every `@rekey.dev/*` package to 2.2.0-rc.7 and follow the before
   and after tables in the migration page.
4. Check each provider's currency list under Billing, Setup, Providers. A
   Razorpay account that sells outside INR needs its other currencies ticked.

## 2.2.0-rc.6

A release candidate on the 2.2.0 line. The Rekey checkout page now takes
payment through all three providers, for subscriptions and one-time
purchases: PayPal, Razorpay and Stripe. The page keeps one layout with your
Application's name, logo and colours; only the payment area changes with the
provider. Stripe moves to API version `2026-09-30.endive`, and Stripe renewal
payments and failed payments are now recorded.

**Upgrading an existing deployment:** one migration runs on boot. Deploy the
portal before or together with the API. Stripe and PayPal webhooks need
Auto-configure pressed once, and Razorpay needs one event added by hand. See
**Upgrade notes** at the end of this section.

### Changed

Every item here changes a behaviour an existing 2.2.0-rc.5 deployment or
integration can see. Read this list before you upgrade.

- **Stripe API version is `2026-09-30.endive` on `stripe` 23.0.0.** Webhook
  endpoints registered before this release keep sending the older payload
  shape, and Rekey reads both. Press Auto-configure to move an endpoint to
  the new version; a hand-made endpoint should pick `2026-09-30.endive`.
- **Stripe renewal payments and failed payments are now recorded.** Stripe
  invoice events were dropped before this release because they carried no
  Application id of their own. They are now routed by the subscription's
  metadata, so renewals create payments, refill credits and extend timed
  licences, a failed renewal moves the subscription to `PAST_DUE` and opens a
  dunning case with its reminder emails, and `payment.succeeded` and
  `subscription.past_due` webhooks are sent. Renewals from before the upgrade
  are not backfilled.
- **A PayPal one-time order is checked before it is captured.** The webhook
  reads the order back from PayPal and compares the buyer, the amount and the
  currency with what the checkout was created for. A mismatch is not captured
  and is recorded as the security event `app.checkout_capture_refused`. An
  order created before the upgrade on a one-time plan that was re-priced since
  is refused the same way.
- **`subscription.activated` is sent once** when several PayPal deliveries
  for one order arrive together. It could be sent once per delivery before.
- **A refund of a Stripe invoice paid partly or wholly outside Stripe is
  refused** with `BILLING_PAYMENT_NOT_REFUNDABLE`; refund it in the Stripe
  dashboard.
- **A second checkout for a plan is refused while a payment is in flight** at
  Razorpay or Stripe, as it already was at PayPal. If the provider cannot be
  asked, the answer is 503 `CHECKOUT_PAYMENT_STATUS_UNAVAILABLE`.

### Added

- **PayPal one-time purchases on the Rekey checkout page**, through PayPal's
  buttons with an order created on the server. Coupons apply as on PayPal's
  own page.
- **Razorpay on the Rekey checkout page**, subscriptions and one-time
  purchases. Your Pay button opens Razorpay's payment window with your
  Application's name, logo and colour, and UPI is listed first for one-time
  INR purchases. One-time purchases on the page use a Razorpay order and
  complete on `order.paid`. Buyers are returned to your app after paying,
  which Razorpay's own subscription page never did.
- **Stripe on the Rekey checkout page**, subscriptions and one-time
  purchases, through Checkout Sessions in elements mode with the Payment
  Element themed from your Application's colours. Trials and coupons work as
  on Stripe's page. Bank debits (SEPA, ACH, Bacs) count as a payment in
  flight, and a debit that fails later reopens the plan for a new checkout.
- **A Stripe publishable key** in the Stripe credentials. It is optional for
  Stripe's own page and required for the Rekey page; saving a key whose mode
  differs from the secret key is refused.
- **Readiness checks for the new flows**: the Razorpay key ID, the Stripe
  publishable key, and a warning until Razorpay has delivered `order.paid`
  when one-time plans are sold.

### Fixed

- A PayPal webhook that failed part-way through a one-time purchase is now
  retried until the purchase is fulfilled.
- The checkout page shows the price the order was created for, not the
  plan's current price.
- Auto-configure adds the order events to a PayPal webhook that already
  exists at Rekey's URL.
- A Stripe invoice paid on a retry after a failed attempt is recorded as
  paid, and can be refunded.
- A failed payment that arrives late no longer moves a cancelled or expired
  subscription back to `PAST_DUE`.
- Two webhooks arriving together no longer open two dunning cases for one
  subscription.

### Upgrade notes

Migration, applied on boot or with `pnpm db:migrate:deploy`:

- `20261002182512_dunning_one_open_case`: closes duplicate open dunning
  cases, keeping the oldest, then adds a unique index that allows one open
  case per subscription.

**Deploy the portal before or together with the API.** An older portal shows
subscription buttons for a one-time purchase.

**PayPal:** press Auto-configure in Panel → Application → Billing → Setup →
Providers → PayPal so the webhook receives `CHECKOUT.ORDER.APPROVED` and
`PAYMENT.CAPTURE.COMPLETED`.

**Razorpay:** add `order.paid` to the webhook in Razorpay Dashboard →
Settings → Webhooks before selling one-time plans on the Rekey page, and keep
automatic capture on.

**Stripe:** add the publishable key in Providers → Stripe → Edit, and press
Auto-configure so the webhook moves to the new API version and receives
`checkout.session.async_payment_failed`. For Apple Pay and Google Pay, add
the portal host under Stripe Dashboard → Settings → Payment method domains.

**After the upgrade, watch Stripe billing for a few days:** payments filed as
unapplied, payments still marked failed that Stripe shows as paid, and more
than one open dunning case for a subscription should all stay at zero.

**Known limit:** refunds of Stripe renewal invoices use Stripe's Invoice
Payments API, which this release has not exercised against a live Stripe
account in CI.

## 2.2.0-rc.5

A release candidate on the 2.2.0 line. It adds contact lists (waitlists,
newsletters and contact forms, with consent proof, erasure and an export for
your email tool), end-user profile questions with onboarding completion and
skip, sign-in and activity tracking per end user, a Users overview with
analytics kept in a daily rollup, and a ban that shuts one person out of an
Application. End-user IP addresses are masked for callers below OWNER and
ADMIN. It fixes billing regressions found in
pre-release testing, makes portal password resets work, and keeps MFA
challenge tokens out of URLs in the panel and the portal.

**Upgrading an existing deployment:** twenty-one migrations run on boot, all
additive for existing data; two of them build indexes on `refresh_tokens`
concurrently. `docker-compose.prod.yml` now refuses to start without
`INTERNAL_CALLER_SECRET` in `.env`. After the API is live, run the
analytics rollup backfill soon and the last-sign-in backfill once. See **Upgrade notes** at the end of this section.

### Changed

Every item here changes a behaviour an existing 2.2.0-rc.4 deployment or
integration can see. Read this list before you upgrade.

- **Breaking for self-hosters on `docker-compose.prod.yml`: it requires
  `INTERNAL_CALLER_SECRET`.** The compose passes it to the `api` and `portal`
  services and fails to render without it, with a message naming the key.
  The panel already read the same `.env` key. The secret is what lets the API
  believe the visitor's browser and country that the portal forwards on a
  sign-in; without it every portal sign-in was recorded as `node`. Generate
  one with `openssl rand -hex 32`. The split hosted compose files already
  required it.

The next seven items are the billing fixes that were written up as the 2.2.0
release notes. They fix regressions found in pre-release testing: external
renewals on org-billed Applications, the organization free tier, usage
idempotency keys, checkout readiness evidence and refund amounts in the
portal.

- **`billing.subscribe()` can return `subscription: null`** (`@rekey.dev/node`
  and `@rekey.dev/react`; the SDK types are now `SelfSubscriptionDto | null`).
  `POST /billing/subscribe` answers `200` with `data: null` when a FEATURE or
  USAGE-only free plan is asked for an organization while the caller's one
  subscription to it is billed to another beneficiary. It used to answer with
  that other beneficiary's subscription. Row answers now carry `activated`.
- **Organizations get the free tier only when claimed.** On an Application
  that bills per organization, `POST /billing/subscribe` naming an
  organization records its claim, and the org view of entitlements then
  applies the free plan's FEATURE flags and included USAGE quantity to it (not
  its per-unit USAGE price; a priced row caps at its included quantity).
  Admins claiming one organization at once are serialised, so it gets one
  subscription row. Claims survive deletion of the claimed plan. Unclaimed
  organizations, and organizations on a per-user Application, are unchanged.
  The credit and licence variant still answers `409
  BILLING_FREE_TIER_ALREADY_CLAIMED`.
- **External billing renewals keep their organization.** A same-id
  `subscription.activated` that omits `subscriber.organizationId` on an
  org-billed Application applies to the existing subscription instead of
  failing on every retry. An event naming an unknown organization or end user
  answers `404`, an erased end user `410`, instead of `500`; a new
  subscription with no organization still answers a retryable `500`.
- **A sender `occurredAt` more than 5 minutes ahead** is ignored for dating a
  cancellation or ordering an activation, and noted on the receipt. A delayed
  older cancellation can still end a newer renewal, so send `occurredAt` on
  every event.
- **`POST /usage/record` refuses a reused `idempotencyKey`** with a different
  quantity or in another UTC month (`409 IDEMPOTENCY_KEY_REUSED`). A retry in
  the same month with a regenerated `occurredAt` still replays.
  `rekey.usage.record()` in `@rekey.dev/node` now accepts `idempotencyKey`.
- **Checkout readiness** warns when there are no paid plans to sell and when
  nothing has shown the provider credentials work (a registered plan, a
  checkout, subscription or payment through the provider, or a verified
  webhook since its secret or mode last changed). Routing and enablement edits
  no longer restart the webhook evidence window; a mode change does. Fresh
  credentials show a provider WARN until one of those happens.
- **`GET /billing/payments` items carry `refundedAmount`**, and the portal
  shows how much of a partially refunded payment came back.

Other changes:

- **Unknown API key scopes are refused.** Minting a key with a scope outside
  the known list answers `400 API_KEY_SCOPE_UNKNOWN`, with the valid scopes in
  `fix` and the rejected ones in `details.unknown`. It used to return 201 with
  a scope that matched nothing. This covers the super-admin, tenant, operator
  token and operator MCP mint paths. Stored keys are untouched.
- **Operator token mints with an unknown scope** answer
  `OPERATOR_SCOPE_UNKNOWN` with `details.unknown` and `details.valid`, instead
  of `BAD_REQUEST`.
- **Every `*` key gains `contacts:write`,** the new standard scope for list
  subscribes. It reaches only lists, and no list exists until an operator
  creates one. `contacts:read` is elevated, so no existing key gains it.
- **Token-link checks run before the account lookup** on password reset,
  magic link and verification re-send. A caller that sends a disallowed
  `resetUrl`, `signInUrl` or `verifyUrl` for an unknown address now gets `400
  AUTH_URL_NOT_ALLOWED` too, where it used to get the constant 200. A token
  URL with a username or password in it
  (`https://attacker.example@app.example.com`) is refused even when its origin
  is registered.
- **A spent backup code at end-user MFA sign-in** answers
  `MFA_BACKUP_CODE_USED`, so the user is told the code was already used. It
  still counts toward the MFA lockout. Step-up, disable and operator sign-in
  keep their existing answers.
- **Custom template previews validate `variables`.** A preview override with
  a bad value, such as a `javascript:` URL, answers `400
  EMAIL_VARIABLES_INVALID`, using the send rules minus `required`.
- **Operator sign-up without a workspace name** answers
  `WORKSPACE_NAME_REQUIRED` instead of `BAD_REQUEST`. The name is optional
  when signing up with a workspace-bound invite key.
- **Portal MFA moves to a cookie.** The code step is `?step=mfa` and reads
  the challenge from the httpOnly `rekey_portal_mfa` cookie. Old
  `/<slug>/login?mfa=...` links no longer open the code step, so a user who is
  on that step during the deploy signs in again.
- **Panel MFA moves to a cookie.** `/mfa-verify` reads the challenge from
  `rk_mfa_challenge`; an old bookmarked `?challenge=` URL is ignored. A
  missing or expired challenge shows "Sign in again" instead of redirecting
  to `/login`.
- **The portal's `PORTAL_BASE_URL` and the API's `PUBLIC_PORTAL_URL` must
  name the same origin.** Portal password resets are refused with
  `AUTH_URL_NOT_ALLOWED` when they differ, and when the API has no
  `PUBLIC_PORTAL_URL`.
- **The API's hosted authorize page** (served when an Application has no
  `hostedAuthorizeUrl`) uses plainer scope wording, shows the host both
  buttons send you to, and says what Deny does. An unnamed client is called
  "An app" instead of "An application".
- **Session rows record the visitor, not your server.** When a secret-key
  caller sends `X-Rekey-Client-User-Agent`, that value becomes the session's
  `userAgent`, so server-side sign-ins stop showing `node` once you upgrade
  `@rekey.dev/nextjs` or use `visitorClient` in `@rekey.dev/astro`.
- **`session.created` carries `platform` and `country`,** and `user.updated`
  has a new `via` value, `server`, for secret-key profile writes.
- **`@rekey.dev/nextjs`, `@rekey.dev/astro` and `@rekey.dev/react` treat
  `END_USER_BANNED` and `END_USER_ERASED` as signed out** and clear the
  session cookies, instead of throwing on every page until the token expires.
- **Billing `fix` strings name the new panel paths.** They point at Billing,
  Setup, Providers (`/applications/:id/billing/providers`), Setup, Status and
  Setup, Settings instead of the old single Billing page. If you match on
  `fix` text, update it; match on `code` instead.
- **`PLAN_SLUG_TAKEN` and `PLAN_INACTIVE`** name a failed or unfinished
  registration correctly. An unregistered plan's `PLAN_INACTIVE` fix names
  `POST .../register`.
- **Profile schema writes are versioned.** `GET
  /tenant/applications/:id/profile-schema` returns `version`; a PUT that sends
  a stale `version` answers `409 PROFILE_SCHEMA_CHANGED`. Removing a select
  option that users picked answers `409 PROFILE_OPTION_IN_USE`.
- **`PUT /admin/tenants/:id/limits`** now records a
  `workspace.limits_set_by_admin` security event with the previous and new
  limits.
- **End-user IP addresses are masked below OWNER and ADMIN.** Only OWNER
  and ADMIN operators whose credential holds `activity:read` see them in
  full. Everyone else (grant holders, restricted members, and an OWNER or
  ADMIN using a PAT or MCP token without `activity:read`) gets the network:
  IPv4 as `/24`, IPv6 as `/48` in canonical CIDR form. This covers session
  lists, impersonation audits, the device list and its block, unblock and
  release responses, and the operator MCP device tools. Secret keys record
  `revealsEndUserIps` at mint: a key minted by an OWNER or ADMIN holding
  `activity:read` (or with the super-admin key) reads full addresses on `GET
  /api/v1/devices` and `POST /api/v1/devices/:id/release`, and a key minted by
  anyone else reads them masked. Keys minted before this release have no
  record and keep reading full addresses; to mask one, mint a replacement as
  a member and revoke the old key.
- **Overview and Billing Overview numbers are cached.** `GET
  /tenant/applications/:id/stats` and `.../billing/stats` are served from a
  stale-while-revalidate cache: fresh for 60 seconds, then served stale for
  up to 15 minutes while one request refreshes them. Each computation runs
  read-only under a 4 second statement budget, and at most two dashboard
  computations run at once per API process. Under a cold burst a request can
  get `503 ANALYTICS_BUSY` with `Retry-After`, or `503 ANALYTICS_TIMEOUT`,
  instead of queueing on the database pool.
- **`get_workspace_overview` MRR is computed in SQL.** It used to load at
  most 10,000 subscriptions and counted non-recurring plans, so it could
  disagree with Billing Overview. It now counts only `SUBSCRIPTION` plans,
  yearly plans as `floor(amount / 12)`, per currency, with no cap, and matches
  Billing Overview. `activeSubscriptions` is an uncapped count.
  `list_applications` reads end-user counts and DAU and MAU from the rollup
  when it has them (`endUserCountAsOf`, `activityDay`, `activityTimezone`).
- **Panel:** Billing is split into routed Setup tabs (Status, Providers,
  Checkout page, Events, Settings); old `/billing?edit=` and `?webhook=` links
  redirect. Team has Members, Application access and Invitations tabs. Lists
  sit under a new Audience group. Users has an Onboarding tab that replaces
  Profile fields (`/profile-fields` redirects). "Usage" is labelled Meters,
  and "Force-logout all end-users" is now Sign out all end-users. Email is its
  own nav group, and a new Settings tab holds Promote, Sign out all end-users
  and Disable (`/lifecycle` redirects there). The OpenID Connect provider
  switch and "Your own sign-in page" moved to OAuth clients; Developer, Access
  is now "Allowed origins & IPs". The Applications list hides disabled
  applications until you turn on "Show disabled". Destructive actions share
  one outlined red button, settings forms save through one sticky bar, and
  wide tables scroll inside their card on a phone. The sidebar shows the
  running Rekey version.

### Added

- **Contact lists.** Collect waitlist, newsletter and contact-form sign-ups
  into lists that belong to an Application, with versioned consent text and
  consent proof (version, time, network prefix, source page).
  - Operator routes under `/api/v1/tenant/applications/:id/lists` to create,
    edit, archive and restore lists, read members and submissions,
    unsubscribe a member, and export CSV (`export.csv`, workspace OWNER or
    ADMIN only, audited). A new `audience` operator scope guards them;
    viewer and billing grants never reach contacts.
  - `GET /api/v1/lists/:key` returns a list's form, and `POST
    /api/v1/lists/:key/subscribe` takes sign-ups. A publishable key (only on
    a list with Public capture on, and only for an Application with allowed
    origins) always gets `202 { status: "received" }`. A secret key with
    `contacts:write` gets the real outcome. A browser can never re-subscribe
    someone who left, and a secret key that names a visitor
    (`X-Rekey-Client-Ip` or `X-Rekey-Relay: browser`) is treated as the
    browser it relays. Browser traffic is rate limited per visitor, per list
    and per workspace day, and fails closed.
  - `GET /api/v1/lists/:key/members` with the elevated `contacts:read`
    scope, keyset-paged with `updatedSince` for incremental sync, for your
    email tool. `GET /api/v1/lists` returns lists with counts.
    `DELETE /api/v1/lists/:key/members/:email` unsubscribes.
  - Webhooks `contact.subscribed`, `contact.submission.created` and
    `contact.unsubscribed`. Rekey sends no email from a list.
  - Erasure: erasing an end user also erases their contact in that
    Application, `DELETE /tenant/applications/:id/contacts/:contactId`
    (workspace OWNER only) erases a contact who never signed up, and an
    erased address cannot be re-captured from a browser for 30 days. DSAR
    exports gain `contacts`. Submissions older than a list's
    `submissionRetentionDays` are pruned.
  - Workspace limits `maxContacts`, `maxContactLists` and
    `contactCaptureDailyCap`, reported in both limits views.
  - SDKs and tools: `rekey.lists` in `@rekey.dev/node` (`list`, `get`,
    `subscribe`, `members`, `iterateMembers`, `unsubscribe`);
    `subscribeToList` in `@rekey.dev/nextjs/server`; `<NewsletterForm>`,
    `<ContactForm>` and `useListSubscribe` in `@rekey.dev/react`, which work
    through a Server Action with no provider; `rekey lists ls` and `rekey
    lists export --format csv|jsonl` in `@rekey.dev/cli`; operator MCP read
    tools `list_contact_lists` and `get_contact_list_stats` (counts only).
  - Panel: Audience, Lists with Members, Submissions, Settings and Embed
    tabs. See docs/lists.md.
- **Profile questions and onboarding.** Define up to 50 profile fields per
  Application (`text`, `select`, `number`, `boolean`, `url`, `date`), each
  writable by the user or only by your server.
  - `GET|PUT /tenant/applications/:id/profile-schema`, `GET
    /api/v1/profile-schema`, `PATCH /api/v1/users/me/profile`, `PATCH
    /api/v1/users/:id/profile` and the operator equivalent.
  - `POST .../onboarding/complete` (refuses with `PROFILE_INCOMPLETE` until
    every required field is answered) and `POST .../onboarding/skip`, for the
    user, your server and the operator. Rekey records what happened and never
    blocks a user; `onboardingStatus` is `pending`, `completed` or
    `skipped`.
  - Webhooks `user.onboarding_completed` and `user.onboarding_skipped`.
  - `rekey.users.updateProfile`, `completeOnboarding` and `skipOnboarding` in
    `@rekey.dev/node`; `skipOnboarding` and `completeOnboarding` on
    `RekeyBrowserClient` in `@rekey.dev/react`; `onboardingStatus()` and the
    profile types in `@rekey.dev/shared-types`. See docs/profile-fields.md.
- **Sign-in and activity per end user.** `lastSignedInAt`, `lastSignInVia`,
  `signInCount` (counted from this deploy), `lastActiveOn`, `lastPlatform`,
  `platformsSeen` and `lastCountry` on the end-user record, plus each
  session's platform, OS, browser, app version and country. Clients can say
  what they are with an optional `client: { platform, appVersion }` on
  sign-up, sign-in, MFA verify, magic-link verify, passkey complete and the
  OAuth callback. `GET /tenant/applications/:id/stats` gains `activeUsers`
  (`d1`, `d7`, `d30`) and a 30-day `activitySeries`. `GET
  .../end-users/:euid/insights` returns one user's sign-ins, active days,
  platforms, places and profile answers. The operator end-user list sorts by
  `lastSignedInAt`. `@rekey.dev/node` gains `clientUserAgent`, and
  `@rekey.dev/astro` gains `visitorClient(Astro)`. See docs/analytics.md.
- **Ban an end user.** `GET|POST
  /api/v1/tenant/applications/:id/end-users/:euid/ban` and `POST .../unban`,
  with a required reason. A ban ends every session and MCP grant, deletes
  outstanding links and codes, and answers `403 END_USER_BANNED` on every
  sign-in path, refresh and live access token, after the credential
  verifies. Licence verify answers `reason: "suspended"`. Webhooks
  `user.banned` and `user.unbanned`, a `banned` filter on the end-user list,
  and an Access tab in the panel. Subscriptions keep billing, and apps
  verifying RS256 tokens offline see the ban only when the access token
  expires. See docs/auth.md.
- **`rekey init --owner-email` hands over the workspace.** `POST
  /api/v1/admin/operator-invites` accepts `tenantId`, `email` and `role`, and
  returns `inviteUrl`. A bound invite joins its redeemer to that workspace at
  sign-up (password or OAuth) or through the panel's accept-invite page, and
  only for the bound email. `rekey init` prints the link and, under
  `--json`, `ownerInvite`. Against an older API it revokes the unbound key
  and stops with `CLI_INVITE_UNBOUND`.
- **`GET /tenant/applications/:id` returns `portalBaseUrl`,** and the panel
  Portal page warns when the API has no portal base instead of showing a
  placeholder URL as live.
- **`GET /api/v1/admin/applications/:id/plans/:slug/entitlements`,** a
  super-admin read of a plan's rows.
- **Users overview.** `GET /api/v1/tenant/applications/:id/analytics/users`
  (`overview:read`) returns counts, rates and dates, never a person, in
  sections that succeed or fail on their own: `kpis`, `activity`, `mix`,
  `onboarding`, `retention`, `security`, `billing` and `usage` (the last two
  need `billing:read`). It takes `range` (7d, 30d, 90d, 12m or custom),
  `compare` and filters for platform, country, sign-in method, sign-up source,
  onboarding, verified, MFA, plan, paying, organization and one profile
  question. Each section is cached like `/stats`. The route allows 120
  requests a minute per operator per Application, of which at most 30 may
  compute an uncached section; cache hits do not count, and the operator MCP
  tool draws on the same allowance (`429 RATE_LIMITED` with `Retry-After`
  past either). The panel's new Users, Overview tab shows it, with the
  filters in the URL, and Users, Onboarding shows completed, skipped and
  pending counts. See docs/analytics.md.
- **Daily analytics rollup.** Once an hour one API replica rolls each
  Application's yesterday and today into `application_activity_days` and a
  daily population snapshot, in the Application's reporting timezone. Once
  the rollup holds days, the Users overview answers ranges up to 366 days
  from it (unfiltered, or filtered on one of platform, country or sign-in
  method); other requests use live data and are capped at 63 days. Set
  `ANALYTICS_ROLLUP_ENABLED=false` to turn the job off.
- **Reporting timezone per Application.** `PATCH
  /api/v1/tenant/applications/:id/settings` with `{ "reportingTimezone":
  "Asia/Kolkata" }` (write access and `overview:write`) sets the zone the
  rollup counts days in; the default is `UTC`. A zone the database does not
  know, such as a legacy alias like `Asia/Calcutta`, answers `400
  REPORTING_TIMEZONE_UNSUPPORTED`; send the current name. Days already rolled
  up keep their zone, and a change rolls the Application up at once. `GET
  /tenant/applications/:id` returns `reportingTimezone`.
- **How each account was created.** End users carry `createdVia`:
  `password`, `magic_link`, `oauth:<provider>`, `operator`, `import` or
  `billing`. Accounts created before this release read `unknown`. It is on
  the end-user object, the operator list and detail, the insights endpoint
  and the DSAR export.
- **End-user list filters.** `GET /tenant/applications/:id/end-users` takes
  `activeFrom`, `activeTo`, `inactiveForDays`, `minSignIns`, `createdFrom`,
  `createdTo`, `createdVia`, `platform`, `country`, `lastSignInVia`,
  `onboarding`, `mfa`, `plan` and `org`, sorts by `lastActiveOn`, and returns
  `lastActiveOn` and `lastCountry` on each row. `plan` needs `billing:read`
  and `org` needs `organizations:read` (403 otherwise, never ignored).
- **Users analytics for agents and scripts.** Operator MCP tool
  `get_user_analytics` (`overview:read`) and `rekey analytics users --app
  <id>` in `@rekey.dev/cli`, which takes an operator token through
  `--operator-token` or `REKEY_OPERATOR_TOKEN`.
- **Applications list filters.** `GET /api/v1/tenant/applications` takes
  `status`, `environment`, `q`, `sort` (`created`, `name`, `activity`) and
  `include=summary` (active API keys and last active day, each left out when
  the caller cannot read it). With no parameters it behaves as before.

### Fixed

- **Portal password reset works.** The portal's reset link was refused on
  every portal app because the portal origin was never an allowed token-link
  destination, and the link carried no `{token}`. The app's own portal pages
  are now allowed while the portal is on (scoped to the app's slug on a
  shared host, or a verified custom domain). Nothing is written into
  `redirectUrls`. An email transport is still needed.
- **A new `pnpm dev` stack gets a working portal password reset:** the root
  `.env.example` sets `PUBLIC_PORTAL_URL`.
- **`rekey init --owner-email` left the new workspace unreachable.** The
  named owner got a second, empty workspace. See **Added**.
- **Panel selects show the saved value after a save,** and a second save no
  longer reverts an earlier select change (auth methods, team roles and
  scopes, end-user role, organization role, billing provider mode, template
  category, plan interval, audit log filter).
- **Panel:** "MFA enabled" and "MFA disabled" banners now appear; the
  member role select has an accessible name; the Account security summary
  says when the passkey read failed; the Operator MCP page takes its address
  from the API instead of showing `<set NEXT_PUBLIC_API_URL>`; the "Mint your
  first API key" step ticks when any of the three newest applications has a
  key; a damaged profile answers form reports "Nothing was saved" instead of
  a false success; list and onboarding snippets are valid, copy-paste ready
  code; copy that said failed sign-ins are not recorded, and several stale
  cross-links, are corrected. Success banners no longer vanish a moment
  after an action, and the Overview pages say "not visible to your role" or
  "could not be read" instead of showing zeros.
- **Trial eligibility on an external-only Application** no longer says the
  provider "cannot host a checkout".
- **Sign-up email rules explain apex versus subdomain.** `example.com`
  matches only that domain and `*.example.com` its subdomains; the panel
  notes an apex listed without its wildcard. Matching is unchanged.

### Security

- **End-user IP addresses are no longer readable in full by every member.**
  A member with a viewer, billing or admin grant read raw addresses from
  sessions, devices and the MCP device tools, an APP_ADMIN could mint a
  secret key to read them, and an OWNER token narrowed below `activity:read`
  still got them. All three now get the masked network (see **Changed**).
- **Dashboard reads cannot exhaust the database pool.** Analytics and
  Overview computations run read-only, under a statement budget, through a
  small per-process slot pool, and uncached computations are rate limited
  per operator.
- **An account-existence oracle on token links is closed.** A publishable
  key sending a disallowed reset, magic-link or verification URL got 400 for
  a known address and 200 for an unknown one. Both now get the same refusal.
- **Portal reset links are scoped to the app's own pages.** Every app shares
  the portal host, so the allowance is the app's `/<slug>/` path only, with
  dot segments, encoded separators, backslashes and empty segments refused
  both raw and parsed.
- **MFA challenge tokens no longer travel in URLs** in the panel (after
  password, magic link, OAuth and the Cloud handoff) or the portal. They were
  visible in access logs and browser history.
- **Token URLs with userinfo are refused,** so
  `https://attacker.example@app.example.com` cannot pass as a registered
  origin.
- **`X-Rekey-Client-User-Agent` is believed only from a secret key** or a
  proven internal caller, and a session's country only when
  `TRUST_CF_IPCOUNTRY` is on and the request came through the proven proxy or
  the portal (see **Upgrade notes**).
- **A bound operator invite is single-use, hashed and expiring,** and needs
  the bound email at sign-up and at accept.
- **Erasure** now also clears the new fields: profile answers, onboarding
  skip time, platform and country roll-ups, ban reasons, and `country` in
  stored `session.created` deliveries.

### Upgrade notes

Migrations, applied on boot or with `pnpm db:migrate:deploy`. All are
additive for existing data:

- `20260928095906_operator_invite_workspace_binding`: three nullable columns,
  a foreign key and an index on `operator_invites`.
- `20260928100124_mfa_used_backup_code_hashes`: a defaulted column on the MFA
  credential.
- `20260928102404_org_free_tier_claims` and
  `20260928114500_org_free_tier_claim_survives_plan_delete`: the
  `organization_free_tier_claims` table, its `plan_id` nullable with `ON
  DELETE SET NULL`.
- `20260928102922_billing_credentials_secrets_updated_at`: a defaulted
  `billing_credentials.secrets_updated_at`, backfilled from `updated_at`.
- `20260929022614_end_user_sign_in_counters`: sign-in columns on
  `end_users`, an index, and `applications.activity_tracked_since`, which is
  the deploy time for existing Applications.
- `20260929022615_end_user_last_sign_in_desc_index`: an index on `end_users`
  for the last-sign-in sort. It is a plain `CREATE INDEX`, so writes to
  `end_users` wait while it builds; on a very large table, plan for that.
- `20260929022650_end_user_ban`: three nullable ban columns.
- `20260929024139_end_user_daily_activity`: `last_active_on`,
  `activity_bits` and an index.
- `20260929025448_session_client_platform`: client columns on
  `refresh_tokens` and roll-up columns on `end_users`.
- `20260929030658_end_user_profile_fields` and
  `20260929030659_profile_schema_version`: `applications.profile_schema`
  (default `[]`), its version, `end_users.profile` (default `{}`) and
  `onboarding_completed_at`.
- `20260929041500_contact_lists`: five new tables for lists, consent
  versions, contacts, members and submissions.
- `20260929050545_contact_erasure_tombstones`: a new table of hashed,
  30-day erasure markers.
- `20260929211359_onboarding_skipped_at`: one nullable column.
- `20260929222101_end_user_created_via`: one nullable column, no backfill.
- `20260929223019_application_reporting_timezone`: `reporting_timezone`,
  default `UTC`.
- `20260929230642_analytics_rollups`: the `application_activity_days` and
  `application_population_snapshots` tables.
- `20260929230643_refresh_tokens_app_live_head_idx` and
  `20260929234841_refresh_tokens_app_created_at_idx`: two indexes on
  `refresh_tokens`, each in a migration of its own and built with `CREATE
  INDEX CONCURRENTLY IF NOT EXISTS`, so sign-ins and refreshes keep writing
  while they build. An interrupted build leaves an INVALID index that `IF NOT
  EXISTS` then skips. Check with `SELECT indexrelid::regclass, indisvalid
  FROM pg_index WHERE NOT indisvalid;`, and recover with `DROP INDEX
  CONCURRENTLY IF EXISTS "<name>";`, then `prisma migrate resolve
  --rolled-back <migration>` and another `prisma migrate deploy`. See
  docs/analytics.md, "Daily rollup".
- `20260930043535_api_key_reveals_end_user_ips`: one nullable column on
  `api_keys`; existing keys stay null and keep reading full addresses.

**Breaking, `docker-compose.prod.yml`:** set `INTERNAL_CALLER_SECRET` in
`.env` before you pull this release (`openssl rand -hex 32`). The same value
is used by the API, panel and portal. Without it `docker compose up` fails
on `services.api.environment.INTERNAL_CALLER_SECRET`.

**Soon after the API is live, backfill the analytics rollup:**

```bash
docker compose exec api node apps/api/dist/scripts/backfill-analytics-rollup.js
```

Do not put this off. The activity bits it reads hold 63 days, so each day
you wait loses one more day of per-day history. It writes UTC days only,
never yesterday or today, and never a day that already has a row, so a
re-run does nothing. An Application that fails is logged and skipped, and
the script exits non-zero so a re-run picks it up.

**Known limits of the Users overview.** Requests the rollup cannot answer
(filters such as verified or onboarding, or several dimensions at once) are
computed live over the whole population. With about 20 operators forcing
such computations at once on a large Application, they can take 9 to 12
seconds, or answer `503 ANALYTICS_BUSY` or a `pending` section. Repeat views
are served from the cache.

**After the API is live, backfill the last sign-in** once, on each API:

```bash
docker compose exec api node apps/api/dist/scripts/backfill-last-sign-in.js
```

It fills `lastSignedInAt` and `lastSignInVia` from each user's newest stored
`user.signed_in` event, in batches, only where they are empty. It is safe to
re-run and to stop half way. `signInCount` is not backfilled and counts from
the deploy.

**`TRUST_CF_IPCOUNTRY` is opt-in and off by default.** With it off, no
country is ever recorded. Turn it on only when the API sits behind a proxy
that sends `API_PROXY_SECRET` and that proxy receives nothing but Cloudflare
traffic (its origin locked to Cloudflare's address ranges, or authenticated
origin pulls). Anyone who can reach that proxy another way can write
`CF-IPCountry` themselves. Requests that skip the proxy are ignored either
way. The hosted portal forwards its visitor's country only when it is proven
by `INTERNAL_CALLER_SECRET`. See docs/analytics.md.

**Contact-list limits.** Workspaces have three new limit keys in
`Tenant.limits`: `maxContacts`, `maxContactLists` and
`contactCaptureDailyCap`. Absent or `null` means unlimited, so nothing
changes until you set them, with `PUT /api/v1/admin/tenants/:id/limits` or
`DEFAULT_TENANT_LIMITS` for new workspaces. On Rekey Cloud they come from the
plan entitlements `max_contacts`, `max_contact_lists` and
`contact_capture_daily_cap` (FEATURE, INT). Without rows, Free falls back to
500 contacts and 1 list and Standard to 25,000 contacts with unlimited
lists. Existing workspaces keep their limits until their next billing
webhook or `/plan-change`.

New environment variables:

- `TRUST_CF_IPCOUNTRY` (API), default off. See above.
- `ANALYTICS_ROLLUP_ENABLED` (API), default on. `false` turns the hourly
  rollup off.
- `PUBLIC_PORTAL_URL` is not new, but must now be the same origin as the
  portal's `PORTAL_BASE_URL` for portal password resets to work.
- `INTERNAL_CALLER_SECRET` is required by `docker-compose.prod.yml`.

New webhook events: `user.banned`, `user.unbanned`,
`user.onboarding_completed`, `user.onboarding_skipped`,
`contact.subscribed`, `contact.submission.created` and
`contact.unsubscribed`. New scopes: `contacts:write` (standard, in `*`),
`contacts:read` (elevated; minting it needs `audience:read` on the
Application) and the operator scope `audience`.

New error codes. The API codes are listed in docs/errors.md:

- Lists: `LIST_NOT_FOUND`, `LIST_KEY_TAKEN`, `LIST_CAPTURE_UNPROTECTED`,
  `LIST_MEMBER_NOT_FOUND`, `CONTACT_LIST_QUOTA_EXCEEDED`,
  `CONTACT_QUOTA_EXCEEDED`, `CONTACT_NOT_FOUND`, `CONTACT_CURSOR_INVALID`,
  `CONTACT_CONSENT_REQUIRED`, `CONTACT_CONSENT_STALE`,
  `CONTACT_FIELDS_INVALID`, `CONTACT_EMAIL_DOMAIN_NOT_ALLOWED`,
  `CONTACTS_RATE_LIMITED`.
- Profile: `PROFILE_SCHEMA_INVALID`, `PROFILE_SCHEMA_CHANGED`,
  `PROFILE_FIELD_KEY_IMMUTABLE`, `PROFILE_OPTION_IN_USE`,
  `PROFILE_FIELD_UNKNOWN`, `PROFILE_FIELD_READ_ONLY`,
  `PROFILE_FIELD_INVALID`, `PROFILE_TOO_LARGE`, `PROFILE_INCOMPLETE`.
- Auth and keys: `END_USER_BANNED`, `BAN_REASON_INVALID`,
  `MFA_BACKUP_CODE_USED`, `API_KEY_SCOPE_UNKNOWN`,
  `OPERATOR_INVITE_EMAIL_MISMATCH`, `OPERATOR_INVITE_TENANT_REQUIRED`,
  `OPERATOR_INVITE_EMAIL_REQUIRED`, `WORKSPACE_NAME_REQUIRED`.
- Analytics: `ANALYTICS_BUSY`, `ANALYTICS_TIMEOUT`,
  `ANALYTICS_RANGE_INVALID`, `ANALYTICS_RANGE_TOO_LONG`,
  `ANALYTICS_FILTER_UNSUPPORTED`, `REPORTING_TIMEZONE_UNSUPPORTED`.
- Thrown by the SDK and CLI themselves: `CLIENT_IP_MISSING`
  (`subscribeToList` without a visitor address), `CLI_INVITE_UNBOUND`,
  `CLI_SECRET_KEY_MISSING`, `CLI_LISTS_FORMAT_INVALID`,
  `CLI_LISTS_STATUS_INVALID`, `CLI_OPERATOR_TOKEN_MISSING`.

## 2.2.0-rc.4

A release candidate on the 2.2.0 line. It adds a Rekey-hosted checkout page
(beta, PayPal subscriptions first), custom transactional email templates sent
by key, lifecycle webhooks for sign-in, invitations and trials, and sign-up
email domain rules. The default transactional emails are redesigned and carry
the sending Application's brand. It also makes MFA codes and sign-in challenge
tokens single-use, tightens how billing webhooks apply to subscriptions and
payments, and stops `rekey --help` printing the super-admin key.

**Upgrading an existing deployment:** six migrations run on boot, all additive
(new tables, nullable or defaulted columns, one index, one backfill). Before
deploying, check your Stripe credential rows for a key whose mode contradicts
the stored mode, or every Stripe event on that Application answers 409. See
**Upgrade notes** at the end of this section.

### Changed

Every item here changes a behaviour an existing 2.2.0-rc.3 deployment or
integration can see. Read this list before you upgrade.

- **MFA codes and sign-in challenge tokens are single-use** (end-user and
  operator). A TOTP code is accepted once per factor; a code at or below the
  last accepted time step answers `MFA_CODE_REUSED` (401 at sign-in verify and
  on step-up, 422 at setup-confirm). A `mfaChallengeToken` that already
  completed a sign-in answers `401 MFA_CHALLENGE_USED`, so an integration that
  retries `mfa-verify` after a success must sign in again. Consequences a user
  will notice: the code that confirms enrolment cannot also complete the
  first sign-in, and a code used for step-up cannot be used again for another
  action in the same window. A reused code does not count toward the MFA
  lockout. Step-up routes that answered `STEP_UP_REQUIRED` or
  `MFA_CODE_INVALID` for a spent but correct code now answer
  `MFA_CODE_REUSED`; `/auth/mfa/challenge` keeps its `200 { ok: false }`.
  Replay state lives in Redis and fails closed with `503
  DEPENDENCY_UNAVAILABLE`. Challenge tokens issued before the deploy stay
  valid until they expire (5 minutes at most).

- **Checkout creation is limited for every checkout**, on the provider's page
  as well as the new Rekey page, for every provider: 10 an hour and 30 a day
  per end-user, 20 an hour per client IP (`CHECKOUT_LIMIT_PER_IP_HOUR`) and
  1000 an hour per Application (`CHECKOUT_LIMIT_PER_APP_HOUR`). Over a limit
  answers `429 CHECKOUT_RATE_LIMITED`. An unvouched client address skips the
  IP ceiling, so a backend starting checkouts for many buyers from one server
  IP is held only by the per-end-user and per-Application limits.

- **Checkout return URLs are checked.** A `successUrl` or `cancelUrl` that is
  not http(s) (`javascript:`, `data:`, `ftp:`) is refused with `400
  CHECKOUT_RETURN_URL_INVALID` before anything is created. An http(s) URL
  whose origin is not the Application's App URL, one of its redirect URLs, or
  (when enabled) its hosted portal still succeeds, and the response carries
  `warnings: [{ code: "CHECKOUT_RETURN_URL_UNREGISTERED", ... }]` plus an
  `app.checkout_return_url_unregistered` security event. A later minor release
  refuses these; register your origins now.

- **A second checkout while the first is being paid is refused.** `POST
  /billing/checkout` answers `409 CHECKOUT_PAYMENT_IN_PROGRESS` when a session
  for the same end-user and plan is confirming, or PayPal reports its
  subscription approved. A failed PayPal read answers `503
  CHECKOUT_PAYMENT_STATUS_UNAVAILABLE`.

- **Stripe Checkout no longer forces card-only.** Stripe shows Link, wallets
  and whatever the account enables. A `checkout.session.completed` with
  `payment_status: "unpaid"` activates nothing, and
  `checkout.session.async_payment_succeeded` completes it. Endpoints
  registered before this release do not receive that event, so a buyer paying
  by a delayed method stays PENDING until the endpoint is re-registered
  (Auto-configure). Newly registered Stripe endpoints are pinned to the API
  version the client uses (`2024-11-20.acacia`), and the translator reads the
  `2025-03-31.basil` shapes for period end and invoice subscription too.

- **Stripe events whose `livemode` contradicts the verifying credential's
  mode are answered `409 WEBHOOK_MODE_MISMATCH`** and not applied. They stay
  unprocessed, so Stripe retries, and a retry after the credential is fixed
  applies. A cross-Application Stripe event now answers the documented `400
  WEBHOOK_APPLICATION_MISMATCH`, not 500.

- **Billing webhooks and grants refuse a second subject on the same plan.**
  An operator or super-admin grant for a different organization (or the
  personal account) on a plan the end-user already holds answers `409
  BILLING_SUBSCRIPTION_SUBJECT_CONFLICT`, where it answered `200
  {"activated":false}`. Subscription imports report it as a per-row error. An
  external `subscription.activated` for a different subscription id and
  another subject is acknowledged with 200 but not applied, and the reason is
  on the webhook event receipt. External activations dated (`occurredAt`) at
  or before a sender-dated cancellation of the same subscription are ignored,
  and a live row's period moves only when the new `currentPeriodEnd` is at
  least 24 hours later. Senders should include `occurredAt`.

- **Refunds are accumulated.** `payment.refunded` adds to `refundedAmount` and
  sets `PARTIALLY_REFUNDED` or `REFUNDED` from the total. A refund past the
  payment (`BILLING_REFUND_EXCEEDS_PAYMENT`), in another currency, or for an
  unrecorded payment is refused and noted on the receipt. Stripe
  `charge.refunded` is now translated and subscribed on newly registered
  endpoints. Tenant billing stats and super-admin payment volume count
  `amount - refundedAmount`. The operator payments list gains
  `refundedAmount`, and its status filters (and the operator MCP
  `recent_payments`) accept `PARTIALLY_REFUNDED`.

- **The free-tier default plan must cost nothing.** Setting
  `billingConfig.defaultPlanSlug` to a plan with a nonzero `amount` or any
  `pricePerUnitCents` answers `409 BILLING_FREE_PLAN_NOT_FREE`, the code
  `POST /billing/subscribe` already used. An Application
  that already has a priced default stops granting its entitlements to
  non-subscribers and records an `app.default_plan_ignored` security event.

- **End-user subscription responses are an allowlist.** `GET
  /billing/subscription`, cancel, subscribe, checkout's `subscription` and
  `include=subscription` now return `SelfSubscriptionDto`. `metadata` keeps
  only `checkoutSessionId` and `oneTime`, and `entitlementOverrides` is
  dropped. Code that read operator notes or provenance keys through a
  publishable key or user token must move to the secret-key operator routes.
  `@rekey.dev/node` and `@rekey.dev/react` return the new type.

- **Coupons.** Creating an `AMOUNT` coupon without `currency` answers `400
  COUPON_CURRENCY_REQUIRED`, and a currency outside ISO 4217 answers `400
  COUPON_CURRENCY_INVALID`. An existing `AMOUNT` coupon with no currency is
  refused at validate and checkout with `COUPON_CURRENCY_REQUIRED`; the panel
  flags it. A checkout already open with one still records its redemption
  when paid.

- **Subscription webhooks omit `entitlements` when the grant cannot be
  resolved**, where they sent `[]`. Absent means unknown, not zero; do not
  downgrade on it. `subscription.entitlements_updated` is not sent in that
  case. Every subscription event payload now carries `trialEndsAt`.

- **Billing refusals name the right provider.** On an Application whose only
  enabled provider is inbound-only (`external`), checkout and
  `GET /billing/trial-eligibility` answer `BILLING_PROVIDER_INBOUND_ONLY`
  instead of `BILLING_CREDENTIALS_NOT_CONFIGURED`. A plan create whose
  provider registration fails now says the plan exists and names the
  `/register` route, with `details.planId`, `planSlug` and
  `registrationStatus: "FAILED"`.

- **Cancelling a checkout that was never completed by `subscriptionId`
  answers `409 SUBSCRIPTION_CHECKOUT_UNFINISHED`.**

- **`user.updated` is emitted**, and operator-created end-users emit
  `user.created`. `user.updated` fires on the self and operator end-user
  PATCH, the first email verification and a magic link that proves an
  unverified address, only when a value changed, with `data.changed` (field
  names) and `data.via`. Operator-created users emit `user.created` with
  `via: "operator"`, and password sign-up's `user.created` now carries
  `via: "password"`. Subscribers to `*` receive both.

- **Welcome email timing.** OAuth-first sign-up now sends the welcome email
  once. With `requireEmailVerification` on, an unverified sign-up gets the
  welcome after its first verification instead of at sign-up.

- **Default transactional emails are redesigned and branded.** The nine
  built-in emails use the Application's portal branding (`displayName`,
  `logoUrl`, `primaryColor`, support contact) with the Application name as the
  fallback, get a hand-built plain-text part and dark-mode styles, and
  subjects name the app ("Reset your Acme password"). The "Sent via Rekey"
  line is gone. Timestamps gain readable UTC variables (`expiresAt`,
  `changedAt`, `enabledAt`, `graceEndsAt`, `receivedAt`) beside the existing
  `*Iso` ones, and the payment-failed reminder gains `portalUrl` and an
  "Update payment method" button when the hosted portal is on. Customised
  templates are unchanged.

- **Email sender identity is separate from credentials.** Saving email
  credentials without `fromName` or `replyTo` now keeps the stored values
  instead of clearing them. On the shared pool a stored `fromName` shows as
  `<name> (via <deployment>)` and Reply-To is now sent. A `fromName` with a
  control character answers `400 EMAIL_FROM_NAME_INVALID` at write time. From
  display names are quoted per RFC 5322 on every send.

- **`requireEmailVerification` cannot be switched on without a verification
  URL.** The auth-config PATCH (and the operator MCP `update_auth_config`)
  answers `409 EMAIL_VERIFICATION_URL_REQUIRED` when the Application has no
  `appUrl`, no http(s) redirect URL and no `DEFAULT_APP_URL`.

- **`API_KEY_INVALID` and `PUBLISHABLE_KEY_INVALID` name the public API
  origin**, not an in-cluster host, and the OpenAPI `servers` entry uses it.

- **`@rekey.dev/cli`: usage errors under `--json` are JSON.** Unknown options
  and commands, and missing arguments, go to stderr as `CLI_USAGE_ERROR`, exit
  1. `--limit` (1 to 100) and `--offset` (0 or more) are checked before any
  request (`CLI_LIST_LIMIT_INVALID`, `CLI_LIST_OFFSET_INVALID`).

- **Rekey Cloud Standard includes three production applications per
  workspace;** Free keeps one.

### Added

- **Rekey checkout page (beta, PayPal subscriptions).** `POST
  /billing/checkout` can return a Rekey-hosted page on the portal
  (`/<slug>/checkout/chk_live_...`) with the Application's branding, the order
  summary, an auto-renewal disclosure and PayPal's own buttons. It is switched
  on per Application and per payment mode in Panel, Billing, Checkout page,
  with a failure behaviour of falling back to the provider's page (default)
  or refusing. The body accepts `mode: "redirect" | "embedded"`, and the
  response gains `mode` and `checkoutSessionId`. Card data stays in PayPal's
  windows, and activation stays webhook-only: a PayPal approval only moves the
  session to confirming after Rekey checks the subscription with PayPal.
  - Public routes under `/api/v1/checkout-sessions/:token` (view, status,
    fallback, `paypal/approved`) and `GET /api/v1/checkout/probe/:nonce`.
  - Tenant routes `GET`/`PATCH /tenant/applications/:id/checkout`,
    `GET .../checkout/readiness` (eight checks per mode, Test and Live) and
    `GET .../checkout/status`.
  - Kill switch `CHECKOUT_EMBEDDED_ENABLED=false` on the API.
  - Portal branding gains Terms, Privacy and Refund policy URLs, shown in the
    checkout page footer.
  - Webhook events record the mode of the credential that verified them
    (`WebhookEvent.mode`); readiness counts only live events received since
    the credentials were last saved.
  - New security events `app.checkout_embedded_fallback`,
    `app.checkout_embedded_refused`, `app.checkout_mode_mismatch`,
    `app.checkout_confirmation_refused` and `app.checkout_settings_updated`.
- **Custom transactional email templates.** Register a template in Panel,
  Email, Custom templates (draft, preview, test send to yourself, publish as
  an immutable version), then send it by key with `POST /api/v1/email/send`
  and a secret key holding the new elevated scope `email:send`. The call
  carries only `template`, `to`, `variables`, `version` and `idempotencyKey`,
  never a subject or HTML. Custom templates send only through the
  Application's own Resend or SMTP, never the shared pool. Variables are typed
  and validated, an idempotency key names one attempt, and sends are capped
  per workspace (`EMAIL_SEND_DAILY_CAP`, `EMAIL_SEND_RECIPIENT_HOURLY_CAP`,
  overridable in `Tenant.limits`). `notification` templates carry RFC 8058
  one-click unsubscribe, which stops notification mail only; password resets,
  sign-in links and `critical` templates keep arriving. The suppressions list
  shows what each entry stops. The preview returns and highlights undeclared
  variables. See docs/email-templates.md.
- **`@rekey.dev/node`: `rekey.email.send()`**, with `isEmailSendError()`,
  `emailVariableIssues()` and `EMAIL_SEND_ERROR_CODES`.
- **Email sender identity.** `PATCH
  /api/v1/tenant/applications/:id/email-sender` sets `fromName`, `replyTo` and
  `supportEmail`, and the panel's Email settings gain a Sender section.
  `supportEmail` feeds the "Need help?" footer of the default emails and is
  returned as `ApplicationDto.supportEmail`.
- **Lifecycle webhooks:** `session.created` (once per real sign-in, with
  `via` and `firstSignIn`, never on refresh or organization switch),
  `organization.invitation.created`, `organization.invitation.accepted`,
  `subscription.trial_started` and `subscription.trial_will_end` (3 days
  before `trialEndsAt`, once per trial end). Subscribers to `*` receive them.
- **`isNewUser` on the auth result**, true when the sign-in created the user
  (password sign-up, magic link, OAuth), and `SignedInSession` from
  `@rekey.dev/nextjs`'s `signIn`, `signUp` and `mfaVerify`.
- **`authConfig.welcomeEmail`**: `on_signup` (default, unchanged timing),
  `on_verified` or `off`, on the panel Auth page, the auth-config PATCH and
  the operator MCP.
- **Sign-up email domain rules**, `authConfig.signupRestrictions`:
  `allowedDomains`, `blockedDomains` (exact or `*.` subdomains) and
  `blockDisposable` (a vendored list). Self sign-up from a refused domain
  answers `403 SIGNUP_EMAIL_DOMAIN_NOT_ALLOWED`; operator create and import
  skip the rules, and existing users keep signing in. Panel: Auth, Sign-up
  email rules.
- **`GET /api/v1/auth/oauth/providers`** lists an Application's usable OAuth
  providers (id and name only) for the publishable key.
  `rekey.auth.listOAuthProviders()` in `@rekey.dev/node`; in
  `@rekey.dev/react`, `useOAuthProviders()` and `<SignIn>` / `<SignUp>` fetch
  them when given `oauthStartUrl` or `oauthStartAction`.
- **`GET /api/v1/billing/subscriptions`** returns every live subscription of
  the user or organization, and cancel accepts `subscriptionId`.
  `listSubscriptions` and the `subscriptionId` option in `@rekey.dev/node` and
  `@rekey.dev/react`. The portal lists each live subscription with its own
  Cancel button and never offers a plan the buyer already holds.
- **Operator MCP:** `list_organizations`, `add_organization_member` and
  `set_organization_member_role`, with MCP annotations on tools and the
  `fix` of every `RekeyError` returned. Member adds and role changes from the
  panel and MCP record `app.organization_member_added` and
  `app.organization_member_role_changed`.
- **`licenses.listMine` (`@rekey.dev/node`) and `listMyLicenses`
  (`@rekey.dev/react`) take `{ organizationId }`.**
- **"Secured by Rekey" attribution** for workspaces whose `Tenant.limits`
  sets `emailAttribution: true` (Rekey Cloud Free). Off everywhere else,
  including every self-hosted install.
- **Panel:** the email template editor opens uncustomised templates with the
  default loaded as editable blocks, and the API returns the default's
  `designJson`.

### Fixed

- **`npx @rekey.dev/cli` and `npx @rekey.dev/mcp` did nothing.** The entry
  check compared a symlinked bin path with the real file, so both exited 0
  without running. They now run under npx and `node_modules/.bin`.
- **Portal sign-in errors say what happened.** A locked-out or throttled
  customer is told to wait (with the minutes when known), service failures
  say the account is fine, and forgot-password no longer claims a link is on
  its way after a failed request. Error pages outside the billing dashboard
  no longer say nothing was charged.
- **A fresh clone reaches its API.** The root `.env.example` sets `REKEY_URL`
  and `PORTAL_BASE_URL`, and the panel says so when `REKEY_URL` is missing.
- **The panel login shows a message for every error code**, including
  `DEPENDENCY_UNAVAILABLE`.
- **The `SIGNUP_DISABLED` fix** names ways in that exist.
- **A pending checkout's binding window** is measured from when its session
  was opened, not from the row's last write.
- **Panel:** form rows stay aligned when a field shows a hint or an error, and
  the plan Amount hint no longer shows two amounts.
- **Documentation:** docs/portal.md describes what the portal serves, the
  forgot-password route states when a secret-key caller learns an address is
  unknown, and docs/billing.md, the SDK READMEs and CONTRIBUTING.md are
  corrected against the code.

### Security

- **`rekey --help` printed the super-admin key.** The CLI's global options
  took `SUPER_ADMIN_KEY` and `REKEY_URL` as commander defaults, which help
  prints. Anyone with the key exported who ran `--help` put it on screen and in
  any captured log. Help now reads `(env: SUPER_ADMIN_KEY)`; the values are
  read from the environment only when the flag is absent. Rotate
  `SUPER_ADMIN_KEY` if help output may have been captured.
- **MFA replay.** One TOTP code verified several times, one challenge token
  plus one code minted two sessions, and 8 concurrent requests could all spend
  one backup code. All three are closed; see **Changed**.
- **Passkey sign-in starts are rate limited** to 10 a minute per Application
  and visitor address, on the end-user and operator routes.
- **Tokens in query strings stay out of the API's logs.** Values of `code`,
  `key`, `invite` and names ending in `token`, `secret`, `password`,
  `signature`, `ticket` or `challenge` are redacted, as are checkout tokens in
  paths.
- **PayPal webhook verification uses the raw body**, not a re-serialised one.
- **SMTP connects to the address it checked**, closing a DNS rebinding gap,
  with SNI and certificate checks still on the hostname.
- **`.gitignore` covers every `.env.*`** except the examples.

### Upgrade notes

Migrations, applied on boot or with `pnpm db:migrate:deploy`:

- `20260926120000_custom_email_templates` and
  `20260926180000_email_unsubscribe_category`: new template tables, new
  `EmailLog` columns and status `pending`, `EmailSuppression.category`.
- `20260927000510_hosted_checkout_sessions`: `checkout_sessions` table and
  checkout columns on `applications` (default: provider's page).
- `20260927013324_webhook_event_mode`: nullable `webhook_events.mode`.
- `20260927135137_end_user_welcome_email_pending`: a defaulted boolean.
- `20260927141304_lifecycle_first_sign_in_and_trial_will_end`: two nullable
  columns, an index on `subscriptions (status, trial_ends_at)`, and a backfill
  that marks every existing end-user as already signed in, so none reads as a
  first sign-in.

New environment variables for the API, all optional:

- `CHECKOUT_EMBEDDED_ENABLED`, unset means on.
- `CHECKOUT_LIMIT_PER_IP_HOUR`, default `20`, and
  `CHECKOUT_LIMIT_PER_APP_HOUR`, default `1000`.
- `EMAIL_SEND_DAILY_CAP`, default `1000`, and
  `EMAIL_SEND_RECIPIENT_HOURLY_CAP`, default `10`.
- `EMAIL_UNSUBSCRIBE_SECRET` (32+ characters), `EMAIL_UNSUBSCRIBE_SECRET_ID`
  (default `k1`) and `EMAIL_UNSUBSCRIBE_PREVIOUS_SECRETS`. Unset, unsubscribe
  links are signed with a key derived from `JWT_SECRET` and stop working if it
  changes.

New error codes (each is listed in docs/errors.md):

- Checkout: `CHECKOUT_RATE_LIMITED`, `CHECKOUT_RETURN_URL_INVALID`,
  `CHECKOUT_PAYMENT_IN_PROGRESS`, `CHECKOUT_PAYMENT_STATUS_UNAVAILABLE`,
  `CHECKOUT_EMBEDDED_NOT_READY`, `CHECKOUT_READINESS_FAILED`,
  `CHECKOUT_MODE_MISMATCH`, `CHECKOUT_SESSION_NOT_FOUND`,
  `CHECKOUT_SESSION_EXPIRED`, `CHECKOUT_SESSION_COMPLETE`,
  `CHECKOUT_CONFIRMATION_REFUSED`, `CHECKOUT_CONFIRMATION_LIMIT`,
  `CHECKOUT_FALLBACK_UNAVAILABLE`, `CHECKOUT_EMBEDDED_UNSUPPORTED`; warnings
  `CHECKOUT_RETURN_URL_UNREGISTERED` and `CHECKOUT_EMBEDDED_FELL_BACK`.
- Billing: `BILLING_SUBSCRIPTION_SUBJECT_CONFLICT` (409),
  `BILLING_REFUND_EXCEEDS_PAYMENT`, `SUBSCRIPTION_CHECKOUT_UNFINISHED`,
  `COUPON_CURRENCY_REQUIRED`, `COUPON_CURRENCY_INVALID`,
  `WEBHOOK_MODE_MISMATCH`.
- Auth: `MFA_CODE_REUSED`, `MFA_CHALLENGE_USED`,
  `SIGNUP_EMAIL_DOMAIN_NOT_ALLOWED`, `EMAIL_VERIFICATION_URL_REQUIRED`.
- Email: `EMAIL_TEMPLATE_NOT_FOUND`, `EMAIL_TEMPLATE_NOT_PUBLISHED`,
  `EMAIL_TEMPLATE_INVALID`, `EMAIL_TEMPLATE_KEY_TAKEN`,
  `EMAIL_TEMPLATE_LIMIT_REACHED`, `EMAIL_VARIABLES_INVALID`,
  `EMAIL_TRANSPORT_NOT_CUSTOM`, `EMAIL_SENDER_DOMAIN_MISMATCH`,
  `EMAIL_RECIPIENT_NOT_END_USER`, `EMAIL_IDEMPOTENCY_KEY_REUSED`,
  `EMAIL_SEND_IN_FLIGHT`, `EMAIL_SEND_OUTCOME_UNKNOWN`, `EMAIL_RATE_LIMITED`,
  `EMAIL_DELIVERY_FAILED`, `EMAIL_FROM_NAME_INVALID`,
  `EMAIL_REPLY_TO_INVALID`, `EMAIL_SUPPORT_EMAIL_INVALID`.
- CLI: `CLI_USAGE_ERROR`, `CLI_LIST_LIMIT_INVALID`, `CLI_LIST_OFFSET_INVALID`.

Also:

- Before deploying, find Stripe credential rows whose key contradicts the
  stored mode (an `sk_live_` key on a `test` row, or the reverse). Older rows
  defaulted to `test`. Keys are encrypted, so compare them through
  `billingCredentialsService.loadDecryptedWithMode`, not SQL.
- Re-register Stripe webhook endpoints (Auto-configure) to receive
  `checkout.session.async_payment_succeeded` and `charge.refunded` and to pin
  the API version.
- Register every origin your checkout `successUrl` and `cancelUrl` use as the
  App URL or a redirect URL.
- A root `.env` for local development needs `REKEY_URL` and
  `PORTAL_BASE_URL`; copy them from `.env.example`.

## 2.2.0-rc.3

A release candidate on the 2.2.0 line. Most of it is about sessions: the
operator panel, the hosted portal, rekey.dev, `@rekey.dev/nextjs` and
`@rekey.dev/astro` could spend a refresh token without storing its
replacement, and the next request then replayed the spent token, which made
the API revoke every session the user had on every device. Refresh now happens
where the new cookies can be stored, the API forgives a replay that is only a
race, and a refresh that may have spent the token signs the browser out
instead of replaying it. It also isolates tenants from each other's slow
webhook receivers, resizes the rate limits for a busy Application, and closes
several open redirects.

**Upgrading an existing deployment:** one migration adds a nullable column,
`docker-compose.yml` now requires `JWT_SECRET` and `SUPER_ADMIN_KEY`, and the
default per-key rate limit goes from 6000 to 30000 a minute. See **Upgrade
notes** at the end of this section for every new environment variable and its
default.

### Breaking changes

Every item here changes a behaviour or a configuration that an existing
2.2.0-rc.2 deployment or integration can see. Read this list before you
upgrade.

- **Outbound webhook delivery is capped per endpoint and per Application.** At
  most 4 sends to one endpoint and 8 sends for one Application
  (`WEBHOOK_APP_MAX_IN_FLIGHT`) are in flight at once, across every replica.
  A send over a cap waits its turn and does not use up a retry. After 5 failed
  sends in a row an endpoint's circuit breaker opens for 60 seconds: attempts
  that come due meanwhile are recorded as failed without a request (the error
  starts `Not sent:`) and stay on the normal retry schedule. One success closes
  it. The request timeout is still 10 seconds and is now configurable with
  `WEBHOOK_TIMEOUT_MS` (1000 to 30000). Each replica runs up to 50 sends at
  once, where it ran 10. Deliveries to one endpoint can arrive slightly more
  out of order than before; order was never guaranteed.

- **An Application can register at most 100 webhook endpoints.** Creating
  another answers `400 WEBHOOK_ENDPOINT_LIMIT_REACHED`.

- **`@rekey.dev/astro`: `getSession` no longer refreshes outside
  `rekeyMiddleware`** unless you pass `{ refresh: true }`. Called from a
  component after the response had started, it rotated the token and lost the
  new cookies. With `rekeyMiddleware` installed nothing changes. Without it,
  add the middleware, or pass `{ refresh: true }` from an API endpoint or
  top-level page frontmatter; otherwise a visitor whose access token has
  expired reads as signed out.

- **`@rekey.dev/nextjs` and `@rekey.dev/astro` sign the browser out when a
  refresh may have spent the token.** The API rotates the token before the
  work that can fail, so a 5xx from the API itself (one that carries a Rekey
  error envelope), a timeout, or a connection dropped mid-request can come
  after the rotation. Both SDKs now clear the session cookies then;
  `rekeyRefreshHandler` redirects to `signInUrl?next=…&reason=session_interrupted`,
  and the Astro middleware continues signed out. The session is kept on a 429
  or any other 4xx that is not a `REFRESH_TOKEN_*` verdict, on a connection
  that was never made (DNS failure, connection refused), and on a 502, 503 or
  504 with no Rekey envelope, which is a proxy answering while the API
  restarts. The refresh route answers those with `503` and `Retry-After`.

- **A refresh token replayed moments after its rotation answers `401
  REFRESH_TOKEN_RACED` and revokes nothing.** That is two tabs or two server
  instances refreshing at once, or a retry after a lost response. It applies
  within `REFRESH_TOKEN_REUSE_WINDOW_SECONDS` (default 15, 0 turns it off)
  while the replacement is unused, to end-user and operator sessions. Nothing
  is issued to the replayer, and the replay is recorded as
  `user.refresh_token_raced` or `operator.refresh_token_raced`. A later
  replay, or one after the replacement was used, still answers
  `REFRESH_TOKEN_REUSED` and revokes every session. A client that gets
  `RACED` should use the replacement another request stored, or sign in again
  if it has none, and must never present the spent token again. Both SDKs do
  this: `rekeyRefreshHandler` redirects back to `next` with the session
  cookies untouched, and the Astro middleware sends a GET or HEAD back to its
  own URL. The same token racing a second time is treated as finished.

- **`@rekey.dev/nextjs`: `rekeyMiddleware` changes three answers.** A Server
  Action whose `Origin` is `null` or otherwise not a URL gets a `403` (Next 15
  crashed on it with a 500). Only GET and HEAD are redirected to the refresh
  route; a Server Action or other non-GET with a stale session reaches your
  code and refreshes in place through `auth()`, where it used to fail with a
  405. Its redirects carry `Cache-Control: no-store`, and the sign-in bounce
  keeps the page's query in `next`.

- **Refresh routes answer a cross-site navigation with an interstitial page
  instead of rotating.** `rekeyRefreshHandler`, the Astro middleware and the
  panel, portal and rekey.dev refresh routes rotate only when
  `Sec-Fetch-Site` is `same-origin` or `none`, or absent. Any other request,
  `same-site` included, gets a small `no-store` page that asks for the same
  URL again from this origin, with no script, no cookie and no API call.

- **`@rekey.dev/nextjs`: the access cookie's `maxAge` follows the access
  token's own lifetime**, read from its `exp` and `iat`, instead of a fixed
  default.

- **Auth and lifecycle webhooks are written in the transaction of the change
  they announce.** `user.created`, `password.changed`, `email.verified`,
  `session.revoked`, `mfa.enabled`, `mfa.disabled`, `user.deleted`,
  `user.erased`, the `device.*` events and `license.deactivated` can no longer
  be lost to a crash after the change committed. Payloads are unchanged. The
  trade: if the event cannot be written, the request now fails and the change
  does not commit, where it used to succeed and log the lost event.

- **A busy connection pool answers `503 DEPENDENCY_UNAVAILABLE`, not 500.**
  Prisma `P2024` (no pool connection in time) and `P2028` (a transaction that
  could not start or ran past its timeout) now map to 503 with `Retry-After`,
  and `details.reason` says `pool_busy` or `transaction_timeout` rather than
  claiming the database is unreachable.

- **New `503 USAGE_RECORD_BUSY` on `POST /api/v1/usage/record`.** Capped
  records now queue only behind records for the same end-user or
  organization, not every subject of the meter, and a record that waits more
  than 2 seconds for that lock is refused with this code. Nothing was
  recorded or charged, so retrying after `Retry-After` is safe.

- **Rate limits are resized for one Application at 50,000 DAU.**
  - The per-secret-key budget (`RATE_LIMIT_API_KEY_MAX`) defaults to 30000 a
    minute, was 6000. `RATE_LIMIT_USAGE_MAX` follows it.
  - The per-Application ceiling across sign-in, sign-up, MFA, magic link,
    reset and verify has its own setting, `RATE_LIMIT_AUTH_CEILING_MAX`
    (default 3000). It used `RATE_LIMIT_MAX` (100), which let one address
    using the public publishable key block an Application's sign-in.
  - One client address is still held to `RATE_LIMIT_MAX` (100 a minute) across
    an Application's auth routes, now as a per-(Application, client IP)
    bucket.
  - A secret-key caller can name the visitor in `X-Rekey-Client-Ip`, and its
    auth requests are then counted per visitor, like browser traffic. The API
    believes the header from a secret key only, and only for these limits.
  - Auth traffic with no visitor address (a secret key that does not send the
    header, or a publishable key behind a proxy the API cannot identify) gets
    a new cap, `RATE_LIMIT_AUTH_UNATTRIBUTED_FAILURE_MAX` (default 300 failed
    sign-in or MFA attempts per Application a minute). Past it, only accounts
    that already failed in the window are refused; every other account still
    signs in, and sign-up, reset, magic link, verification and passkeys are
    never refused by it. The first time an Application reaches it in a
    window, the API writes an `auth.unattributed_failure_cap_reached`
    security event.

- **MCP token introspection counts against the secret key's budget.**
  `POST /api/v1/mcp/:slug/oauth/introspect` (`rekey.mcp.introspect()`) was
  held to a 30-a-minute sign-in limit per address; it now uses the key's own
  budget (30000 a minute by default). The operator
  `POST /api/v1/tenant/mcp/oauth/introspect` counts against the token's
  operator at the authenticated limit.

- **Hosted authorize pages must ask for consent.** The rekey.dev hosted
  authorize page now shows Allow and Deny before any authorization code is
  minted. New `POST /api/v1/mcp/:slug/oauth/authorize/preview` describes an
  authorization request (client name, confirmed `redirect_uri`, scope and the
  account's email) without minting a code, for a hosted page to build its
  consent screen from. `POST /api/v1/mcp/:slug/oauth/authorize/grant` now
  returns the confirmed `redirect_uri`, and includes it in `details` on
  refusals made after it was confirmed. See docs/auth.md.

- **`GET /health/live` and `GET /health/ready` report `version` and
  `commit`.** `version` is the running release; `commit` is the
  `REKEY_COMMIT` build argument, or `unknown`.
  `scripts/check-deployed-version.sh` compares it with npm.

- **`docker-compose.yml` refuses to start without `JWT_SECRET` and
  `SUPER_ADMIN_KEY`**, and names the missing one. Their old
  `change-me-in-prod` default was too short for the API, so it only ever
  produced a crash-looping container.

- **An operator's refresh keeps the workspace they switched to** (or joined
  through an invitation), instead of moving them back to their oldest
  workspace every time the access token expired. Migration
  `20260923120000_operator_refresh_active_tenant` adds a nullable column to
  `tenant_refresh_tokens`.

- **The API image sets `UV_THREADPOOL_SIZE=16` and runs at most 4 argon2
  hashes at once.** Hashing throughput and memory are unchanged, and outbound
  DNS lookups (webhooks, breached-password checks, email) no longer queue
  behind a burst of sign-ins. A deployment that runs the API outside this
  image should set the variable itself.

### Added

- **`@rekey.dev/nextjs`: `rekeyRefreshHandler()`** on `/server`, the refresh
  route `rekeyMiddleware` redirects to. `export const GET =
  rekeyRefreshHandler();` in `app/api/rekey/refresh/route.ts` is the whole
  route. It follows `next` only to a same-origin path, and requests presenting
  the same token at the same moment share one exchange with the API.
- **`@rekey.dev/nextjs`: `rejectMalformedActionOrigin(req)`** on
  `/middleware`, for hand-written middleware that wants the same 403.
- **`@rekey.dev/nextjs`: `DEFAULT_REFRESH_PATH`** (`/api/rekey/refresh`) and
  **`DEFAULT_SIGN_IN_PATH`** (`/sign-in`).
- **`@rekey.dev/nextjs`: `signIn`, `signUp` and `mfaVerify` forward the
  visitor's address** as `X-Rekey-Client-Ip`: the
  `REKEY_TRUSTED_PROXY_HOPS`-th entry from the right of `X-Forwarded-For`
  (default 1), or `X-Real-IP`. An optional `{ clientIp }` argument overrides
  it, and `null` sends none. With nothing in front of the app, set
  `REKEY_TRUSTED_PROXY_HOPS=0`, or a visitor could pick a fresh rate-limit
  bucket per request.
- **`@rekey.dev/node`: `clientIp`**, client-wide or per call with
  `rekey.with({ clientIp })`, sent as `X-Rekey-Client-Ip` only when it is
  exactly one IPv4 or IPv6 address. `normalizeClientIp` and `CLIENT_IP_HEADER`
  are exported.
- **`@rekey.dev/shared-types/transport`**: `neverConnected` and
  `NEVER_CONNECTED_CODES`, the one list the refresh clients use to tell a
  connection that was never made from one that failed mid-request.
- **Sign-in pages explain an interrupted session.** The panel, portal and
  rekey.dev sign-in pages show one line for `reason=session_interrupted`.
- **New security events:** `user.refresh_token_raced`,
  `user.refresh_token_reused`, `operator.refresh_token_raced`,
  `operator.refresh_token_reused` and `auth.unattributed_failure_cap_reached`.

### Fixed

- **Opening two magic links for a new address at once, or double-clicking
  Accept on a workspace or organization invitation, no longer answers 500 to
  the slower request.** It signs in or joins like the first one, and a new
  user gets one welcome email.
- **A user created through an OAuth provider is written together with its
  identity**, so a failed identity insert no longer leaves a user with no way
  to sign in.
- **One slow or unresponsive webhook receiver no longer delays other
  tenants' deliveries.** A receiver that never answered held every delivery
  slot on a replica for the full timeout. See **Breaking changes** for the
  caps and the breaker that replace this.
- **An MCP server that introspects on every tool call is no longer
  throttled** after 30 calls a minute.

### Security

- **Open redirects in `next` and redirect handling.** A validator that checks
  its input and then returns the path rebuilt by the URL parser is not enough:
  the parser collapses dot segments, so `/..//evil.com` and
  `/%2e%2e//evil.com` come back as `//evil.com`, a URL on another host. Every
  validator below now refuses control characters, backslashes and encoded
  separators, and checks the path it returns as well as the one it was given.
  - `@rekey.dev/astro`'s `safePath` returned those protocol-relative URLs in
    every published version since 2.0.0-rc.7. The hash of a legitimate path is
    now kept alongside the query.
  - The panel's `next` on sign-in, sign-up, MFA, OAuth and passkey sign-in
    had the same dot-segment flaw.
  - rekey.dev's sign-in `next` let `/%09/evil.com` through, which a browser
    follows to another host once it strips the tab.
  - The refresh route the `@rekey.dev/nextjs` README and the rekey.dev
    quickstart showed for apps to copy followed `next` off-site. It is
    replaced by `rekeyRefreshHandler`, which applies the checks above.
  - The portal refuses a slug that is not one plain path segment, so an
    encoded slash (`/%2Fevil.com/...`), which Next decodes, can never become a
    protocol-relative `Location`.
  - The rekey.dev hosted OAuth authorize page could redirect to a
    `redirect_uri` the API had not confirmed. It now shows an error on
    rekey.dev instead, asks for consent before minting a code, and refuses to
    be framed.
- **A refresh token spent during a Server Component render revoked every
  session.** The panel, the portal and rekey.dev refreshed a stale session
  while a page rendered, where Next cannot write cookies, so the replacement
  was lost and the next request replayed the spent token. They now refresh in
  a route handler (`/session/refresh` on the panel and rekey.dev,
  `/<slug>/session/refresh` on the portal) and return to the page.
  `@rekey.dev/astro`'s `getSession` had the same failure; see **Breaking
  changes**.
- **A malformed `Origin` on a Server Action is refused, not stripped.** The
  panel, portal, admin and rekey.dev apps removed an `Origin: null` header to
  stop Next 15 crashing, which made Next skip its CSRF origin check for that
  action. They now answer 403, as `rekeyMiddleware` does.
- **Refresh clients never re-issue tokens from a local cache.** The panel,
  portal, rekey.dev and `@rekey.dev/nextjs` share only an exchange that is
  still in flight, keyed by a hash of the token (and of the device in the
  SDK). A request that arrives with a spent refresh cookie after the exchange
  finished goes to the API, which answers `REFRESH_TOKEN_RACED`, so nobody
  holding a copy of a spent cookie is handed its successor.
- **rekey.dev no longer sends URL query strings to Google Analytics**, where
  email verification and password reset links carried their tokens.
- **Operator refresh-replay security events are filed under the session's
  workspace**, not the operator's oldest one, so another workspace's owner no
  longer sees them.

### Upgrade notes

New environment variables for the API, all optional:

- `REFRESH_TOKEN_REUSE_WINDOW_SECONDS`, default `15` (0 to 60, 0 turns the
  reuse window off).
- `RATE_LIMIT_AUTH_CEILING_MAX`, default `3000`, never below
  `RATE_LIMIT_MAX`.
- `RATE_LIMIT_AUTH_UNATTRIBUTED_FAILURE_MAX`, default `300`.
- `WEBHOOK_TIMEOUT_MS`, default `10000` (1000 to 30000).
- `WEBHOOK_APP_MAX_IN_FLIGHT`, default `8` (1 to 200).
- `REKEY_COMMIT`, a build argument and environment variable for the API and
  billing images, reported by the health probes. Unset reads as `unknown`.
- `UV_THREADPOOL_SIZE`, set to `16` by the API image.

Changed defaults: `RATE_LIMIT_API_KEY_MAX` is `30000` (was `6000`), and
`RATE_LIMIT_USAGE_MAX` follows it. A deployment that set either explicitly to
the old `6000` keeps 6000; remove the setting to get the new default.

For `@rekey.dev/nextjs` apps: `REKEY_TRUSTED_PROXY_HOPS`, default `1`, the
number of proxies in front of the app that append to `X-Forwarded-For`. Set
`0` when nothing sits in front.

Also:

- `docker-compose.yml` needs `JWT_SECRET` and `SUPER_ADMIN_KEY` in `.env`
  (`openssl rand -hex 32` each).
- Run `pnpm db:migrate:deploy` for
  `20260923120000_operator_refresh_active_tenant`. It adds a nullable column,
  so rolling back the code does not need a SQL step.
- A Next.js app using `rekeyMiddleware` needs the refresh route:
  `export const GET = rekeyRefreshHandler();` in
  `app/api/rekey/refresh/route.ts`. Replace a hand-written one; the copies the
  README and quickstart used to show were open redirects.
- An Astro site calling `getSession` without `rekeyMiddleware` needs the
  middleware or `{ refresh: true }`.
- A backend that signs users in with a secret key should send the visitor's
  address (`clientIp` in `@rekey.dev/node`), or its failed sign-ins count
  toward the unattributed cap.

## 2.2.0-rc.2

A minor release, and NOT a patch. It changes what an existing subscriber
resolves at read time and closes an input the API used to accept, so a patch
number would tell integrators "nothing here needs your attention" when something
does. Same reasoning that moved this release line from 2.0.1 to 2.1.0.

**Upgrading an existing deployment:** several migrations in this release
rewrite rows on tables that sign-in depends on, one builds five indexes that
block writes to each table while the index builds, and rolling back needs one
SQL step first. Read
[Upgrading: 2.2.0 migrations and rollback](DEPLOY.md#upgrading-220-migrations)
before you deploy. `docker-compose.prod.yml` also requires three proxy secrets
it did not require in 2.1.x; see **Breaking changes** below.

### Breaking changes

Every item here is a behaviour or a configuration an existing 2.1.x deployment
depends on today. Read this list before you upgrade. The API behaviour an
integrator's code sees comes first, then deployment configuration, then the
operator panel and CLI.

- **`GET /api/v1/billing/subscription` returns a live subscription before an
  unfinished checkout.** A buyer already on a plan (a free tier, say) who opens
  a checkout now reads as that plan, not PENDING, while the checkout settles,
  and a paid plan outranks a free one. A UI that shows "confirming your
  payment" by reading PENDING here has to key that state off its own return
  flag instead. The entry under **Changed** has the full rule.

- **`GET /api/v1/billing/entitlements` without `?organizationId=` answers for
  the end-user in a user-billed Application**, even when the session is
  switched into a team. It used the active organization in every Application.
  Only an Application with `billingSubject: "org"` still resolves the
  organization by default. If a user-billed Application grants credits to an
  organization's pool and reads them from a switched session, pass
  `?organizationId=` now: the default `creditBalance` is the member's personal
  pool. See **Fixed**.

- **Entitlement resolution changes what three existing populations resolve.**
  The Application's free tier is a base layer now rather than another voter in
  the merge: a subject with an override naming a key the free tier also defines
  gets the override's value where they got the larger of the two; a STRING
  feature the free tier also defines no longer overwrites a paying plan's
  value; and two subscriptions defining the same STRING feature are ordered by
  `createdAt` rather than by Postgres row order. An override of `0` on a meter
  now resolves to zero included units. The "A per-subscription entitlement
  override now beats the Application's free tier" entry under **Changed** has
  the full account, including which subjects to check before upgrading.

- **Free-tier rows now sort FIRST in the `entitlements[]` array** returned by
  `GET /billing/entitlements`. A consumer that locates a value with `.find()`
  by key rather than reading `features` will now find the free tier's row where
  it used to find a subscription's. Read `features` instead.

- **A repeat free trial is refused with `BILLING_TRIAL_ALREADY_USED` (409).**
  A buyer gets one trial per Application (`billingConfig.trialPolicy`,
  `once_per_application` by default). Checkout used to grant one every time.
  Retry with `allowWithoutTrial: true` and a NEW `Idempotency-Key` to sell at
  full price, or set `trialPolicy: 'unlimited'` to keep the old behaviour.

- **A plan carrying a CREDIT or LICENSE entitlement cannot carry `trialDays`.**
  Creating a plan, patching one, adding an entitlement to one and the legacy
  `creditsAmount` column all refuse the combination with
  `PLAN_TRIAL_MATERIALISES_ENTITLEMENTS`, because such a trial hands over
  credits or a licence key on day 0 with no inverse.

- **A free plan that materialises CREDIT or LICENSE is claimable once per
  end-user per Application.** Another beneficiary gets
  `409 BILLING_FREE_TIER_ALREADY_CLAIMED`. Reactivating for the same
  beneficiary still works. Existing self-serve activations are backfilled as
  claims, and credits already issued are not clawed back.

- **Buying the same one-off plan twice now delivers twice.** It charged twice
  and delivered once before. If you sell credit packs, buyers who repurchased
  were charged and not credited; the **Fixed** entry says how to find them.

- **`*` on an API key means every standard scope, not every scope.** The new
  `credits:grant` scope (for `POST /api/v1/credits/grant`) is elevated: no key
  that exists today holds it, `*` keys included, and a key gets it only when an
  operator names it at mint. Code that treated `*` as "can call anything" is
  refused on that route with `403 API_KEY_SCOPE_INSUFFICIENT`. See
  [docs/api-keys.md](docs/api-keys.md#elevated-scopes-and-why--does-not-include-them).

- **Webhook endpoints subscribed to `*` start receiving three new events**,
  `credit.granted`, `credit.consumed` and `credit.adjusted`, one per credit
  ledger entry. `credit.consumed` fires once per `POST /usage/record` that is
  charged in credits, so a busy priced meter means one delivery per record. If
  that is more than an endpoint should take, list its events by name. Code with
  an exhaustive `switch` over `KnownWebhookEventType` needs branches for the
  new names (and for the six device and licence events below).

- **Credential routes cap their request body, and answer `413
  PAYLOAD_TOO_LARGE`.** Sign-in, sign-up, magic links, password reset, MFA,
  passkeys and the OAuth token endpoints refuse an oversized body before it is
  read, rather than parsing up to the global 1 MiB. A sign-up whose `metadata`
  is over the cap now gets 413 where it used to get `400 METADATA_TOO_LARGE`.

- **An impersonation token can no longer mint a real session.** Switching or
  clearing the active organization, and linking or unlinking an OAuth provider,
  answer 403 `IMPERSONATION_ACTION_FORBIDDEN` from an impersonated session.
  An ended impersonation's token is also refused on `GET /auth/me` with
  `401 IMPERSONATION_SESSION_ENDED`, as every key-guarded route already did.

- **A FEATURE override of `""`, and a quantity on a licence with no seats, are
  refused** with `ENTITLEMENT_OVERRIDE_INVALID`. Both were previously accepted
  and neither reached anything that read it. See **Removed**.

- **`POST /api/v1/admin/applications/:id/plans` refuses fields it used to drop
  silently.** An unrecognised key, or a typo like `intervall`, answers
  `400 VALIDATION_ERROR` naming the field, where it previously answered 201 and
  built a SUBSCRIPTION plan. Per-kind plans and `trialDays` are created on
  `POST /api/v1/tenant/applications/:id/plans`.

- **`docker-compose.prod.yml` refuses to start without `PANEL_PROXY_SECRET`,
  `API_PROXY_SECRET` and `PORTAL_PROXY_SECRET`.** All three are `:?` in that
  file now. Without them the panel would forward a browser-chosen
  `X-Forwarded-For` and the API would not recognise its own proxy, so every
  per-IP limit protecting sign-in would be off after one warning line at boot.
  Generate each with `openssl rand -hex 32` and put them in `.env` before you
  redeploy. `docker-compose.yml`, the development stack, requires none of the
  three; it still requires `POSTGRES_PASSWORD`, `REDIS_PASSWORD` and
  `ENCRYPTION_KEY`, as it did before.

- **The split-unit compose files require their secrets too.**
  `docker-compose.api.yml` requires `API_PROXY_SECRET` and
  `INTERNAL_CALLER_SECRET` and no longer trusts a bare hop count;
  `docker-compose.panel.yml` requires `INTERNAL_CALLER_SECRET` and
  `PANEL_PROXY_SECRET`; `docker-compose.portal.yml` requires
  `INTERNAL_CALLER_SECRET` and `PORTAL_PROXY_SECRET`. The API, panel and portal
  units must share one `INTERNAL_CALLER_SECRET`.

- **Admin endpoints refuse an address the API cannot verify.** With
  `ADMIN_IP_ALLOWLIST` set, a request that arrives through a proxy the API
  cannot identify is answered `403 ADMIN_IP_UNVERIFIABLE` before the key is
  read. That address belongs to the proxy and is shared by everyone behind it,
  so matching the allowlist against it admitted them all. Set
  `API_PROXY_SECRET` and have the proxy send it as `X-Rekey-Proxy-Secret`, or
  clear the allowlist. The API logs this once at boot.

- **Redis is replaced by Valkey, on a NEW volume, so the store starts empty.**
  See the entry under **Changed** for what that resets and how to remove the
  old volume.

- **Revocation can lag briefly across API replicas.** Operator auth is cached
  per API process and invalidated over Redis. If Redis loses that message, the
  other replicas can keep admitting a revoked session for at most
  `OPERATOR_AUTH_CACHE_TTL_MS` (new, default 5000). Set it to 0 to turn the
  cache off and restore the previous read-every-request behaviour.

- **Erasing or cascade-deleting an end-user is workspace-OWNER only.** Both
  were open to workspace ADMINs, and the cascade delete was gated on `write`
  alone, so a member holding an application grant could delete a person the
  erase form refused them. An ADMIN who handles erasure requests today gets a
  403 naming the OWNER; the data export still works for them.

- **Panel forms need JavaScript to submit.** The 75 forms built on
  `ActionForm` do nothing in a browser with scripts off. See **Changed**.

- **`rekey plans create` no longer accepts `--kind`, `--license-kind`,
  `--license-duration-days`, `--license-seats-allowed`, `--meter-slug`,
  `--price-per-unit-cents` or `--credits-amount`.** They are refused with
  `CLI_PLANS_KIND_UNSUPPORTED`, which names the panel and the tenant route,
  because the admin route behind them never implemented any of it.
- **The session handoff needs a key with `auth:write`.**
  `POST /api/v1/mcp/:slug/oauth/authorize/grant` checked for a secret key but
  not its scopes, so a key minted with only `credits:grant` could mint an
  authorization code that becomes a session and an MCP grant for the user. A
  key without `auth:write` now gets `403 API_KEY_SCOPE_INSUFFICIENT`. Keys
  with `*` are unaffected, and a key that signs users in already holds
  `auth:write`. If your hosted login page calls this with a narrowed key, add
  `auth:write` to it.

### Changed

- **`GET /api/v1/billing/subscription` prefers a live subscription over an
  unfinished checkout.** It used to return the newest row that was ACTIVE,
  TRIALING, PAST_DUE or PENDING, so a free-tier subscriber who opened a paid
  checkout and closed the tab read as PENDING from then on. Every entitlement
  gate built on that answer refused them, and `POST /billing/subscription/cancel`
  cancelled the abandoned checkout instead of their plan. It now returns a
  live (ACTIVE, TRIALING or PAST_DUE) row whenever there is one, newest first,
  with free rows ranked last: a row whose plan costs nothing (no amount and no
  per-unit price) or is the Application's `billingConfig.defaultPlanSlug`. So a
  paid plan wins even when its row is older. A live row that lapses on the read
  no longer hides another live one. PENDING is returned only when nothing is
  live. The end-user MCP `get_subscription` tool picks its row through the same
  service, so a pending checkout, or a paid plan held next to a free one, reads
  the same on both, including for connections made before this release.

  **Check before upgrading** if your UI shows a "confirming your payment" state
  by reading PENDING from this endpoint: a buyer who is already on another plan
  (a free tier, say) now reads as that plan while the new checkout settles. Key
  that state off your own return flag from the provider redirect instead.

  Without `?organizationId=` this route still answers for the end-user
  personally, in every Application. `GET /auth/me?include=subscription` and the
  MCP tool follow the billing subject instead (the active or bound organization
  in an org-billed Application), so pass the organization's id here to compare
  the two.

- **Behaviour change: an `auth:read` secret key can now read the signed-in
  user's own organizations, sessions, passkeys, MFA status and linked OAuth
  identities.** These GET routes used to require `auth:write`, because their
  plugins applied it to every route, reads included, which pushed backends
  that only read to hold a key that can also write. They now require
  `auth:read`, like `GET /users/me` and `GET /users/me/devices` already did.
  Every other method on those routes still requires `auth:write`, and a key
  with `auth:write` (or `*`) can do everything it could before. This is more
  permissive, not less: nothing that worked stops working, but an `auth:read`
  key that was refused with `403 API_KEY_SCOPE_INSUFFICIENT` on
  `GET /users/me/organizations`, `/users/me/organizations/roles`,
  `/users/me/organizations/:id`, `/users/me/organizations/:id/members`,
  `GET /auth/sessions`, `GET /auth/passkeys`, `GET /auth/mfa/status` or
  `GET /auth/oauth/identities` is now answered. Review which services hold an
  `auth:read` key if that matters to you. `include=organization` on
  `GET /users/me` follows its route and needs only `auth:read` too.

- **Behaviour change: `GET /api/v1/auth/me` is rate limited per end-user.**
  It is now keyed on the end-user it resolved (600 a minute by default,
  `RATE_LIMIT_AUTHENTICATED_MAX`) under the 3000-a-minute ceiling for every
  end-user seen from one client IP, instead of per client IP at 100 a minute
  shared by every user a backend resolved from one address, because the token
  is verified before the limiter runs rather than inside the handler. Its
  refusals other than 401 (a frozen Application's 403, an unknown or erased
  user's 404 and 410) count toward the per-IP rejected-credential limit, like
  a 401. A backend resolving many users from one address is better served by
  `GET /users/me` with its secret key, which counts against the key. See
  [docs/rate-limits.md](docs/rate-limits.md).

- **A per-subscription entitlement override now beats the Application's free
  tier.** Resolution merges across sources: booleans OR-true, numbers take the
  max, included usage is summed. The free tier
  (`billingConfig.defaultPlanSlug`) was merged in as one more source, so a
  default the subject had not bought could only ever raise the answer. An
  operator restricting one customer got a 200, a `changed: true` and a
  `subscription.entitlements_updated` webhook carrying the lower value, while
  `GET /billing/entitlements` kept serving the higher one. Lowering a metered
  allowance from 1000 to 50 resolved to **1050**.

  The free tier is now a base layer: it fills in where nothing else answered, and
  it is withheld for a key or meter a per-subscription override names. A plan row
  does **not** withhold it: buying a credit pack that happens to mention a key is
  a top-up, not being on a plan, and does not take a free tier away.

  **Three populations resolve differently after this. Check before upgrading.**

  1. **A subject with an override** naming the same FEATURE key or meter as the
     Application's default plan, who holds no SUBSCRIPTION or USAGE plan (those
     already suppressed the free tier entirely). They now get the override's
     value where they got the larger of the two.
  2. **A subject with a STRING feature the free tier also defines**, with *no
     override involved*. STRING merges last-wins and the free tier used to be
     applied last, so a default of `support_tier: "community"` overwrote a paying
     plan's `"priority"`. The default is now applied first, so it can raise a
     number or turn a boolean true but never replace a value. If you relied on a
     default plan overriding subscriptions for STRING features, it no longer does.
  3. **A subject with two or more subscriptions** that define the same STRING
     feature. Which one wins was Postgres row order and could change after an
     unrelated write; it is now ordered by `createdAt`, so the later subscription
     wins, deterministically.

  An override of `0` on a meter now resolves to **zero included units**. It
  previously had the default plan's allowance added to it. If a later plan edit
  clears the price out from under such an override, the zero still stands rather
  than falling back, because the alternative answers "unmetered" for a subject
  the operator explicitly restricted.

  The free tier's per-unit price still floors the rate charged wherever the free
  tier applies at all, even when its quantity is withheld. A SUBSCRIPTION or
  USAGE plan suppresses the free tier entirely, price included; that is
  unchanged. Free-tier rows also sort first in `entitlements[]` now (see
  **Breaking changes**).

- **`PATCH /api/v1/users/me` returns what `GET` returns.** It returned
  `activeOrganizationId` but not `activeOrganizationRole` and
  `activeOrganizationBaseRole`, which its own published schema marks required,
  so a typed client read `undefined`. Both now come from one builder, and
  `@rekey.dev/node` types both `getCurrentUser` and `updateCurrentUser` as
  `CurrentUserDto`.

- **Security events name their actor: `actorEmail` on every event.**
  `GET /api/v1/tenant/security-events` (JSON, and CSV, where it is a new last
  column) and the operator MCP `recent_security_events` tool now return the
  actor's email, looked up when the log is read: the operator account for
  `operator`, including one who has since left the workspace, the end-user for
  `end_user` (only within the event's Application), and `null` for `system` or
  an actor that no longer exists. The end-user detail's `recentImpersonations`
  gains `operatorEmail` the same way. Nothing is stored twice; `actorId` is
  unchanged.

- **The `APP_BILLING` grant role can now manage provider credentials and issue
  refunds.** Both were plain `write` before, so the billing role could do
  neither. This widens what an existing `APP_BILLING` holder can do; review who
  holds it if that matters to you.

- **Panel forms need JavaScript to submit.** `ActionForm` now lets React
  dispatch the submit, so the navigation a save's `redirect()` triggers is
  committed instead of dropped (#569: on the Auth methods page, 0 of 14 saves
  showed the saved state before, 9 of 10 after). The trade is that React no
  longer emits the fields a no-JavaScript post needs, so the 75 forms that use
  `ActionForm` do nothing without scripts. That path was already partial:
  confirmation buttons, slug checks and the unsaved-changes guard all needed
  scripts. The remaining tenth of #569 was the same lost render that left
  minted secrets unshown, and is fixed with it (see **Fixed**).

- **The end-user page is split into tabs** (overview, subscriptions, devices,
  credits, security, data) with a breadcrumb, and the email page into settings,
  templates, delivery and suppressions. Links to the old single page still land
  on the overview.

- **The panel costs far fewer database queries.** A signed-in operator request
  used to read the operator, the session and the membership (and, on an
  application route, the application; for a MEMBER, the grants) before doing
  anything. Those reads are now cached in each API process and a warm request
  makes none. Measured with the same data: the application overview page went
  from 48 statements to 9, one operator request from 3 to 0.
- **Revocation is still immediate within a replica.** Sign-out, sign out
  everywhere, a revoked session, a password change or reset, and any role,
  scope, grant or membership change drop the cached entry at once in the
  process that made the change and, over Redis, in every other API replica.
  The bound on a lost invalidation is under **Breaking changes**.
- **Per-application stats are one query, cached for 60s in Redis**
  (`rk:stats:app:<id>`). The overview tile's counts may lag by up to a minute;
  turning billing on or off shows at once.
- **Super-admin lists no longer query per row.** The application list made 3
  queries per listed application and the tenant list 5 per tenant, up to 1,500
  at once on a computed sort. Each is now a fixed handful whatever the page
  size. The super-admin overview is one statement for its counts and is cached
  for 60s (`rk:admin:overview`).
- **Tenant MRR on the super-admin tenant list is exact.** It is summed in SQL
  over every active subscription rather than the first 10,000, so `mrrCapped`
  on that list is always `false`.

- The `redis` service in every compose file (`docker-compose.yml`,
  `docker-compose.api.yml`, `docker-compose.prod.yml`) and in CI now runs
  `valkey/valkey:8.1-alpine` instead of `redis:7-alpine`. The floating
  `redis:7-alpine` tag now resolves to Redis 7.4, which is licensed under
  RSALv2/SSPLv1 (source-available, not open source); Valkey is the Linux
  Foundation's BSD-3-Clause fork and is protocol-compatible with this
  codebase's ioredis/BullMQ usage, so no application code changed. The
  service name and `REDIS_URL` / `REDIS_PASSWORD` env vars are unchanged, but
  the volume is a NEW one (`rekey_valkey` / `valkey-data`, replacing
  `rekey_redis` / `redis-data`): Valkey refuses to start on a Redis
  7.4-format AOF/RDB file, which most existing self-hosted volumes already
  are, so an in-place volume swap would have been a boot-time outage on
  upgrade. The store starts empty instead. See "Why Valkey, not Redis" in
  DEPLOY.md for exactly what that resets (queued webhook retries, lockouts,
  rate-limit windows, in-flight PKCE state: all recoverable or
  reconstructible, nothing in Postgres) and how to remove the old volume once
  you've confirmed the deploy is healthy.

### Added

- **`GET /api/v1/auth/me` and `GET /api/v1/users/me` answer who, what and
  which device in one call** (rekey#41). A new `?include=` takes any of
  `entitlements`, `device`, `subscription`, `organization` and `licenses`,
  comma-separated or repeated (`include=a&include=b`), and adds each as a
  top-level property of the same name. `entitlements` and `subscription` are
  what `GET /billing/entitlements/for-user` and `GET /billing/subscription`
  return for the Application's billing subject, from the same service code: in
  an org-billed Application the active organization while the caller is still a
  member of it, otherwise the end-user, including a user-billed session
  switched into a team. `licenses` is what `GET /users/me/licenses` returns for
  that subject, as `{ items, truncated }`: the first 100 rows, `truncated: true`
  when there are more. `device` is the device the token's `dev` claim names, or
  null for a session that is not device-bound. `organization` is the active
  organization with the caller's role and base role. Without `include` the
  response and the queries it runs are unchanged. An unknown value is a
  `400 VALIDATION_ERROR` naming the supported ones. Every session check still
  runs first. With a secret key on `/users/me`, each value needs the scope of
  the route that serves it (`billing:read` for the three billing values,
  `auth:read` for `organization`), and on an Application with billing off the
  billing values answer `403 BILLING_DISABLED` on both routes.
  `@rekey.dev/node` takes it as `auth.getCurrentUser(token, { include })`
  (which calls `/users/me`) and the React browser client as
  `getMe(token, { include })` (which calls `/auth/me`), both typed so a literal
  list gives exactly the properties asked for; a list typed `MeInclude[]` makes
  them optional. `/auth/me` changes how it is rate limited; see **Changed**. See
  [Authorising requests in your own backend](docs/auth.md#authorising-requests-in-your-own-backend).

- **A signed-in user can list their own licences.** `GET /api/v1/users/me/licenses`
  (publishable or secret key plus the user token, `billing:read` on a secret
  key, billing enabled) returns every licence issued to the caller, newest
  first and paginated (`limit`, `offset`), and in an org-billed Application
  whose session acts for an organization the caller belongs to, that
  organization's pooled licences as well. Rows carry `keyPrefix`, never the key
  (only its hash is stored), and not the operator's licence `metadata`. SDK:
  `rekey.licenses.listMine(token)` in `@rekey.dev/node`, `listMyLicenses(token)`
  in the React browser client.

- **A single-feature check.** `GET /api/v1/billing/entitlements/features/:key`
  answers `{ key, granted, value }` for the signed-in user, for the same subject
  `include=entitlements` resolves, without the credit balance read; `granted`
  is `Boolean(value)`, and an unknown key is a 200 with `granted: false`.
  `GET /billing/entitlements/for-user/features/:key?endUserId=` is the
  secret-key variant (`billing:read`). SDK: `billing.hasFeature` /
  `getFeature` in both SDKs, and `hasFeatureFor` / `getFeatureFor` in
  `@rekey.dev/node`.

- **Usage remaining is readable by the user it belongs to** (rekey#473).
  `GET /api/v1/usage/remaining` takes an end-user token with the publishable
  or secret key (`billing:read` on a secret key) and answers, per meter (or one
  with `?meter=`), the `included` quota, the units `used` this period, what is
  `remaining`, the `creditsPerUnit` charged past the quota, and the period
  window. `GET /api/v1/usage/remaining/for-user` answers the same for a named
  end-user or organization with a secret key (`billing:read`). Both are
  computed by the code `POST /usage/record` enforces with, so a record dated
  now of more than `remaining` units is exactly the one refused with
  `402 USAGE_QUOTA_EXCEEDED` (or charged, on a priced meter). Without
  `?meter=` it reports up to 200 meters and says so with `totalMeters` and
  `truncated`. `@rekey.dev/node`: `usage.getRemaining(token)`,
  `usage.getRemainingFor()`; React browser client: `getUsageRemaining(token)`.

- **`GET /api/v1/credits/me/ledger` pages through the signed-in user's own
  credit ledger** (or their active organization's, in an org-billed
  Application), newest first, without `metadata` or the idempotency key.
  Entries written in the same instant now page in a fixed order here and on
  `GET /credits/ledger`. `@rekey.dev/node`: `credits.listMyLedger(token)`;
  React browser client: `listMyCreditLedger(token)`.

- **`POST /api/v1/credits/grant` grants credits with the Application key**
  (rekey#473), to an end-user or an organization pool, through the same ledger
  write as a panel grant. It needs the new **elevated** `credits:grant` scope,
  which `*` does not include (see **Breaking changes**). Minting a key with it
  needs the authority of a panel credit grant, billing-write access and the
  `billing:write` operator scope, on the panel, the operator PAT route and the
  MCP `mint_api_key` tool; otherwise `403 SCOPE_INSUFFICIENT`. The panel's API
  key form has an "Elevated scopes" group, can mint a key holding only that
  scope, and the key list flags elevated scopes. One call grants 1 to
  1,000,000 credits, as `GRANT` (the default) or `REFUND`. `idempotencyKey` is
  required and belongs to this route (stored as `api-grant:<key>`): an exact
  retry grants nothing and returns the original entry, and reusing the key
  with a different amount or reason is `409 CREDITS_IDEMPOTENCY_KEY_REUSED`.
  Each grant is written to the security log as
  `app.credits_granted_by_api_key` naming the key, in the grant's own
  transaction. `@rekey.dev/node`: `credits.grant()`. `@rekey.dev/shared-types`
  exports `STANDARD_API_KEY_SCOPES`, `ELEVATED_API_KEY_SCOPES` and
  `isElevatedApiKeyScope`.

- **Credit webhooks: `credit.granted`, `credit.consumed`, `credit.adjusted`**
  (rekey#473). One per credit ledger entry, enqueued in the transaction that
  writes the entry, so a mirror of balances no longer has to poll. A consume is
  `credit.consumed`, an operator ADJUST of either sign (or any other entry that
  removes credits) is `credit.adjusted`, and every other entry (a grant, a
  refund, a purchase) is `credit.granted`. The payload is `data.credit` with the
  entry id, the subject, the signed `delta`, `amount`, `reason`, the `balance`
  after the entry, the idempotency key, description and time
  (`CreditWebhookData`). A refused consume or an idempotent replay emits
  nothing. `credit.consumed` also fires for usage charged in credits, once per
  charged record; see **Breaking changes** for what that means for `*`
  endpoints. Erasing an end-user scrubs their `credit.*` delivery payloads like
  every other event's. See [docs/webhooks.md](docs/webhooks.md#credits).

- **`GET /api/v1/users/me/licenses` accepts `?organizationId=`**, as every
  other self read does. The caller's own licences are listed with that
  organization's pooled ones; it is member-only
  (`403 ORGANIZATION_NOT_MEMBER` otherwise).
- **An end-user MCP connection can act for one of the user's organizations**
  (#587). When the Application has
  organizations on and the user can act for at least one, the MCP consent page
  asks, after sign-in, whether the connection acts for them personally
  (preselected) or for one of those organizations. The choice is stored on the
  grant, carried in the access token as `oid` and through every refresh, and
  reported by token introspection. `get_profile` now returns the bound
  `organization` with the user's role in it. `get_subscription` and
  `get_credits` answer for the organization when the Application bills
  organizations (`billingSubject: "org"`) and for the user otherwise, by the
  subject rule `include=` uses, and say which in a new `organizationId` field.
  `list_licenses` returns the rows `GET /users/me/licenses` does for the same
  subject: the user's own licences plus, in that case, the organization's
  pool, the newest 100 with `truncated` when there are more.
  `list_my_devices` stays personal.
  The session handoff (`POST /oauth/authorize/grant`) takes `organization_id`;
  omitted is personal, as before, even when the session has an active
  organization. A binding fails closed:
  once the user leaves the organization, is removed, has the role disabled or
  the organization is deleted, the access token is refused and the refresh
  answers `invalid_grant` rather than quietly switching to the personal
  account. Grants made before this change carry no binding and keep acting
  for the user personally. Migration `20260922120000_mcp_grant_organization`
  adds two nullable columns and rewrites no rows. See
  [docs/mcp.md](docs/mcp.md#organization-binding).

- **`GET /api/v1/usage/meters` lists the meter catalogue** with a secret key
  (`billing:read`): slug, name, unit, active flag and fallback credit price
  (`creditsPerUnit`), so a backend learns meter slugs instead of hard-coding
  them. `@rekey.dev/node`: `usage.listMeters()`.

- **Checkout readiness on the public plan list.** Each plan on
  `GET /api/v1/billing/plans` carries `checkout: { ready: boolean }`, false
  when a buyer sent to checkout for it would be refused (no provider
  connected, a plan never registered with Stripe, a trial a provider cannot
  run), so a pricing page can hide it. Only the boolean: which provider refuses
  and how to repair it stay on the operator list, and the two lists serialise
  through one function so they cannot disagree about whether a plan is
  buyable. One extra query per page, however many plans are on it. A free
  default plan reads `ready: false` with no provider connected yet still
  applies, so do not filter the free tier out. SDK type `PublicPlanDto`;
  `PlanDto.checkout.blockers` is now optional in the TypeScript type so a
  public row still assigns to `PlanDto` (the operator routes always send it).

- **The published spec documents `creditsPerUnit`** on the entitlement rows of
  `GET /billing/entitlements` and `GET /billing/entitlements/for-user`. Both
  routes already returned it (USAGE only: credits charged per unit past the
  included quantity, null for a hard cap); only the OpenAPI document and the
  `ResolvedEntitlementDto` type were missing it.

- **`PATCH /api/v1/admin/applications/:id/default-plan`** sets or clears an
  Application's free-tier plan (`billingConfig.defaultPlanSlug`) with the
  super-admin key. It is the only billing setting with no panel control, and
  without it `POST /api/v1/billing/subscribe` answers `BILLING_NO_FREE_PLAN`.
  Same validation as the operator route: the slug must name an active plan.
  It touches nothing else in the billing config, and an unknown body key is a
  400, not silently dropped.

- **The SDKs can reach the billing and device features the API already had.**
  `@rekey.dev/node` and the browser client in `@rekey.dev/react` gain
  `subscribe()` for a free plan a buyer claims themselves, `getTrialEligibility()`
  behind `GET /billing/trial-eligibility`, and `listMyDevices()` /
  `releaseMyDevice()` for the end-user device list. Until now each of these
  endpoints existed with no way to call it from an SDK, so an integrator wrote
  the `fetch` by hand. `<PricingTable>` takes an optional `trialEligibility`
  list and offers a trial only to a buyer the API says may have one, never
  because a plan carries trial days. `allowWithoutTrial` is now part of the
  checkout request type, so the documented way out of
  `BILLING_TRIAL_ALREADY_USED` no longer needs a cast. `DEVICE_LIMIT_REACHED`
  has a type for its `details`, so the "release a device" path it asks for can
  be built. `VerifiedAccessTokenClaims` carries `sid` and `dev`, so an offline
  verifier can read session and device binding. `POST /auth/sign-in` declares
  its 503 in the published spec.

- **Free trials are available again, and `GET /api/v1/billing/trial-eligibility`
  says who may have one.** The 2.1.0 hold (`PLAN_TRIAL_UNAVAILABLE`) is lifted.

  It named two ways a trial lost money. One is fixed: nothing recorded that a
  buyer had already trialled, which `TrialRedemption` and `trialPolicy` now do.
  The other is closed by construction rather than accepted: `provision` has no
  trial gate, so a plan carrying a CREDIT or LICENSE entitlement hands over
  credits or a licence key on day 0, before any money moves, with no inverse.
  A trial is therefore refused on such a plan with
  `PLAN_TRIAL_MATERIALISES_ENTITLEMENTS`, on every write path that could
  introduce the combination (creating a plan, patching one, adding an
  entitlement to one, and the legacy `creditsAmount` column). FEATURE and USAGE
  entitlements resolve at read time and lapse with the subscription, which is
  the ordinary feature-gated SaaS trial `trialDays` exists for.

  The new endpoint answers, per plan, whether **this** buyer may start a trial,
  so a pricing page renders "Start 14 days free" or "Subscribe" from the answer
  rather than from the plan alone. `reason` is evaluated in a fixed order:
  `PLAN_HAS_NO_TRIAL`, `PLAN_TRIAL_MISCONFIGURED` (the plan is currently
  unbuyable and checkout would answer 400), `TRIAL_IN_PROGRESS` (scoped to that
  plan, with `endsAt`), then `ALREADY_REDEEMED`. A buyer's own live reservation
  reads **eligible**, because reporting it as a refusal would make an SDK send
  `allowWithoutTrial` and charge them today for the trial checkout was about to
  grant.

  Advisory, like `coupons/validate`: the authoritative decision is taken under a
  lock at checkout, and the response echoes the resolved `provider` because a
  plan can be unbuyable on one processor and fine on another.

  **A buyer gets one free trial per Application, not one per checkout.**
  `resolveCheckoutTrial` was a pure function of the plan and nothing anywhere
  asked whether the buyer had trialled before, so a buyer could trial, cancel on
  the last day and trial again without limit. On a plan carrying CREDIT or
  LICENSE entitlements each loop handed out a full period of them.

  Checkout now takes a `TrialRedemption` slot before the provider call, the same
  way a coupon reservation works, and refuses a repeat trialist with
  `BILLING_TRIAL_ALREADY_USED` (409). The refusal names the plan already
  trialled and the price that would be charged instead. To sell to that buyer at
  full price, retry with `allowWithoutTrial: true` **and a new Idempotency-Key**:
  checkout stores 4xx responses, so reusing the key answers
  `IDEMPOTENCY_KEY_REUSED`.

  `billingConfig.trialPolicy` chooses the rule: `once_per_application` (the
  default, since trialling two plans is two free months of the product),
  `once_per_plan`, or `unlimited`, which never refuses. The subject is whatever
  the Application bills, so an org-billed Application counts the trial against
  the organization rather than handing a five-person team five trials.

  **Who is affected on upgrade.** Only an Application whose plans already carry
  `trialDays`: writing one first became possible in 2.1.0 and was refused for
  the rest of 2.1.x (`PLAN_TRIAL_UNAVAILABLE`), so the exposed population is
  plans written in that window. They keep working; a repeat buyer now sees a
  409 where they previously got a second free trial. Set
  `trialPolicy: 'unlimited'` to restore the old behaviour.

The entries below came from one contribution by @libworky
(rekey-dev/rekey#39), written while running Rekey as the licensing layer for a
desktop product. Thank you.

- **Devices.** Every session-minting endpoint accepts an optional
  `device: { fingerprint, label }`. The device is registered before any token
  is issued, the access token carries a `dev` claim, and refresh keeps the chain
  bound to that machine. A `max_devices` FEATURE entitlement caps a person's
  active devices, and also caps live activations on PERPETUAL and TIMED
  licences. `authConfig.deviceBinding` (`optional` or `required`) decides
  whether sign-in must name a device. Over the cap, sign-in answers
  `DEVICE_LIMIT_REACHED` with the active devices in `details`, so a client can
  offer to release one. End-users, secret-key backends and operators can list
  and release devices; operators can also block and unblock them. Licence seats
  can be given back with `POST /api/v1/licenses/deactivate`. Nothing changes for
  a client that sends no device or an Application without the entitlement. See
  `docs/devices.md`.

  On refresh, a device refused after the refresh token was already spent answers
  `REFRESH_TOKEN_REVOKED`, with the device code in `details.reason`, so a client
  signs in again instead of retrying a token that no longer works.

- **Import users with the password hashes you already have.**
  `POST /api/v1/users/import` (secret key) takes up to 500 users per call with
  an argon2id or bcrypt hash, so moving a user base onto Rekey no longer means a
  forced reset. A bcrypt hash is re-hashed to argon2id on the user's next
  sign-in. Hash cost is bounded (bcrypt cost 12, argon2id memory, time and
  parallelism limits) so an imported hash cannot turn sign-in into a CPU sink.
  Existing addresses are skipped, never updated, and `emailVerified` defaults
  to false.

- **An `external` billing provider, for sales Rekey never sees.** Your own
  billing system, an invoicing tool or a marketplace posts signed events to
  `POST /api/v1/webhooks/billing/external/<slug>`, with the same signature
  scheme as the webhooks Rekey sends. Rekey activates, renews and cancels
  subscriptions from them, provisions entitlements and records payments. A trial
  the sender reports goes through the same one-trial-per-buyer ledger as
  checkout; a buyer who already had one is granted the subscription without the
  trial. Cancelling these subscriptions through Rekey is refused, since the money
  lives elsewhere. See `docs/external-billing.md`.

- **Subscription import.** To bring over the subscriptions sold before a billing
  system was connected, an import reads a paginated endpoint you host
  (`docs/external-billing-pull.md`) and records, per row, what would happen and
  why. Nothing is written until an operator applies the preview behind a typed
  confirmation. An import never overwrites a live subscription and never matches
  an erased user.

- **An operator support console.** From an end-user's page an operator can
  unlock the account, resend verification, send a password reset with a reason
  that lands in the audit trail, list and revoke sessions, and release every
  device. Workspace owners and admins can grant a subscription with no payment
  behind it (an invoiced sale, a comped account); this is controlled by
  `TENANT_SUBSCRIPTION_GRANTS`. Entitlement overrides and ending an
  impersonation are available in the panel too.

- **Email send control.** Per Application: a master switch, a switch per email
  event, and a suppression list. A mail that was not sent is logged as
  `suppressed` with the reason, and a suppressed send never hands a reset or
  magic-link token back to the caller. Turning off mail the live auth
  configuration depends on (password reset while password sign-in is on,
  verification while it is required) is refused with
  `EMAIL_EVENT_REQUIRED_BY_AUTH_CONFIG`, in both directions.

- **Operator permissions.** A workspace member can be restricted to scopes over
  seven areas (end-users, billing, auth config, developer, organizations,
  activity, overview), each read or write. The grant roles are presets over the
  same scopes, and a request gets the intersection of the two. A caller who
  cannot see an Application still gets 404; one who can see it but lacks the
  scope gets 403 naming the scope. `/me` returns the caller's scopes, and the
  panel builds its navigation from them. Nothing changes for an existing
  workspace until an admin restricts somebody.

- **A per-workspace switch for the operator MCP server.** A workspace owner can
  refuse every operator MCP access token for the workspace. Refresh keeps
  working while it is off, so turning it back on restores every agent without a
  new consent.

- **Token lifetimes are settings.** `END_USER_ACCESS_TOKEN_TTL_SECONDS`,
  `END_USER_REFRESH_TOKEN_TTL_DAYS`, `OPERATOR_ACCESS_TOKEN_TTL_SECONDS` and
  `OPERATOR_REFRESH_TOKEN_TTL_DAYS`. Unset keeps today's 15 minutes and 30 days.

  Ending a session no longer waits for its access token to expire. A password
  change, a password reset or signing out everywhere refuses every access token
  the person holds on its next use. Revoking one session, or releasing or
  blocking one device, refuses only that session's tokens, and the person's
  other sessions carry on without a refresh. The refusal uses the codes clients
  already refresh on. Access tokens issued before the upgrade carry no session
  id, so they run to their natural expiry. A token verified locally with
  `verifyAccessToken` cannot see a revocation; call the API where that matters.

- **Optional log retention, with an optional archive.** `LOG_RETENTION_DAYS`
  prunes `security_events`, `email_logs` and finished `webhook_deliveries`;
  `WEBHOOK_EVENT_RETENTION_DAYS` prunes inbound webhook receipts. **Unset keeps
  every row**, as before. With `LOG_ARCHIVE_S3_*` set, each batch is written to
  any S3-compatible store before it is deleted; a partial set of those settings
  refuses to boot. Erasure does not reach an archived copy.

- **Security events by the person they are about.**
  `GET /api/v1/tenant/security-events?endUserId=` returns events the person did
  and events done to them. The data export for a person now includes what
  operators and the system did to them, without the operator's id.

- **Look up an end-user from your backend.** `GET /api/v1/users?email=`,
  `GET /api/v1/users/:id` and `GET /api/v1/billing/entitlements/for-user` work
  with a secret key, so a licence server or support tool does not need the
  user's token. The publishable key is refused.

- **SDK.** `devices.list`, `devices.release`, `users.get`, `users.getByEmail`,
  `users.import`, `licenses.deactivate` and `billing.getEntitlementsFor`; an
  optional `device` on `refresh`, `completeOAuth`, `verifyMagicLink` and
  `verifyPasskeyAuthentication`; `RekeyError.details`. New webhook events:
  `device.registered`, `device.released`, `device.blocked`, `device.unblocked`,
  `device.limit_reached` and `license.deactivated`. Additive only; code that
  switches exhaustively over the event-name union will see the new names.

- Indexes: `security_events (application_id, type, created_at)`,
  `subscriptions (application_id, status)`, and `created_at` on `end_users`,
  `api_request_logs` and `webhook_deliveries`.
- DEPLOY.md: sizing `DATABASE_POOL_SIZE` per replica against Postgres or Neon
  `max_connections`, and a table of what is cached for how long.
- **The CLI and the standalone MCP package publish at 2.2.0 but do not cover
  the 2.2.0 API surface.** `@rekey.dev/cli` has no commands for devices,
  subscription import, log retention, erasure, external billing providers or
  operator scopes, and cannot create a plan with `trialDays`.
  `@rekey.dev/mcp` ships 9 tools against `/api/v1/admin/*` where the hosted
  operator MCP server exposes 41. Both are usable for what they already do and
  neither is a blocker for this release; the gap is tracked in
  [rekey-dev/rekey#527](https://github.com/rekey-dev/rekey/issues/527).

### Fixed

- **The end-user MCP billing tools refuse when billing is disabled.**
  `get_subscription`, `get_credits` and `list_licenses` answered for an
  Application with billing off, while the REST reads they mirror return
  `403 BILLING_DISABLED`. They now return an MCP tool error carrying
  `code: "BILLING_DISABLED"`, and every tool error that comes from the API's
  own refusals carries its `code`.
- **The panel names the API key behind a credit grant.** An
  `app.credits_granted_by_api_key` event read "system, reason GRANT" on the
  end-user Security and Overview tabs. The actor is now the key, by name and
  prefix, and the Security tab, audit log and Activity show the amount.
- **`GET /api/v1/billing/entitlements` no longer reads a paying user as entitled
  to nothing after they switch into a team.** Without `?organizationId=` it
  resolved the session's active organization in every Application, and in a
  user-billed one an organization holds no subscription, so the answer was
  `features: {}`. It now resolves the subject `include=entitlements`, the
  single-feature check and `GET /users/me/licenses` use: the organization only
  when `billingSubject: "org"` and the caller is still a member, otherwise the
  end-user. An explicit `?organizationId=` is unchanged (member-only). What
  this changes for a user-billed Application that reads an organization's
  credit pool is under **Breaking changes**.
- **Panel: minted secrets are always shown, and tables refresh after an
  action.** On a production build the panel's post-action re-render could be
  left suspended by React after its payload had arrived, so a webhook signing
  secret was minted and never shown, a redirect never landed, and tables kept
  their old rows until a reload. Forms now nudge that render until it commits,
  and the full-page reloads some forms used to work around it are gone.
  Every one-time secret (API keys, webhook signing secrets, invite links,
  licence keys, API tokens, impersonation tokens) is returned by its action to
  a "shown once" dialog with a copy button, instead of a cookie read by a
  redirect; no secret travels in a cookie or a URL any more. The end-user edit
  modal closes on save without the page blinking (rekey-dev/rekey#25). Activity and
  Security tabs show which operator acted, by email. Light-mode primary
  buttons use a darker teal with white text (5.47:1).
- **Plan checkout readiness ignores inbound-only providers when a real one is
  enabled.** With the external billing system enabled beside Stripe, every
  plan read `checkout.ready: false` with a `PROVIDER_INBOUND_ONLY` blocker,
  though buyers are only ever routed to Stripe. Readiness now judges the
  providers a buyer can be routed to; the inbound-only blocker appears only
  when nothing else can host a checkout.

- **A role edit can no longer be undone by a read already in flight.** The
  organization-role catalog cache stored whatever a read returned, so a catalog
  read that started before an edit committed could overwrite the invalidation
  and serve the old role tiers, which decide what a member may do, until the
  entry expired. The cache now discards a load that raced an invalidation.

- **A Next.js or Astro app can sign in when device binding is required.** The
  Next SDK narrowed `signIn`, `signUp` and `mfaVerify` to email and password, so
  an Application set to `deviceBinding: 'required'` refused every one of them
  with `DEVICE_FINGERPRINT_REQUIRED` and nobody could get in. All three now take
  `device`, and so do `auth()` and `refreshSession()`, which matters because
  binding is checked again on each rotation and a mismatch ends every session
  the person has. Astro takes the same field in its config. Omitting it sends
  nothing, so existing call sites are unchanged. The Next SDK also ships an
  `./errors` entry point that sorts a failed sign-in into wrong credentials, a
  device refusal carrying the devices to release, or `retry_later` for the new
  503, which is never read as a wrong password.

- **OIDC response bodies are capped, and panel banners no longer echo the URL.**
  Discovery, token and userinfo bodies from an OAuth or OIDC provider were read
  with no size limit, so an issuer an operator configured could stream an
  unbounded body into memory the whole API shares. They are now read through the
  same capped reader as the external billing pull, and a body over 1 MB fails the
  exchange. Separately, panel error banners fell back to the raw `?error=` value
  when a code was unknown, so a crafted link could put any sentence on the real
  panel host. An unknown code now shows a fixed generic sentence.

- **Imported bcrypt hashes are checked off the event loop.** `bcryptjs` is
  pure JavaScript, so each sign-in against a not-yet-migrated imported account
  blocked every request on the server for about 100 ms at a time (300 ms per
  check at cost 12). The check now runs on a small worker pool. When that pool
  is saturated the request answers 503 `PASSWORD_VERIFY_BUSY` with
  `Retry-After` instead of waiting without bound; it is never read as a wrong
  password and never counts toward lockout. (#511)

- **The customer portal asks what a billing provider can do, not its name.**
  A buyer who picked a payment option other than Stripe, PayPal or Razorpay
  was silently sent through the router's choice instead; the pick is now
  checked against the Application's provider list and forwarded, or refused
  with an explanation. "Managed through your billing account" is decided from
  the provider's capabilities. `GET /billing/subscription` adds
  `providerCapabilities` for that (additive, null when there is no provider).
- **An impersonation token can no longer mint a real session.**
  `POST /users/me/organizations/:id/switch` and
  `POST /users/me/organizations/clear-active-organization` re-mint a token pair and did not refuse impersonation. An operator's 5-minute
  impersonation token could call either, get back an ordinary 30-day refresh
  token with no `imp` claim, and keep a session as the end-user after the
  impersonation was ended: MFA skipped, nothing attributed to the operator. Both
  routes now answer 403 `IMPERSONATION_ACTION_FORBIDDEN`, and the service that
  mints every end-user pair refuses one requested from an impersonated session,
  so a future route cannot reopen it. Linking and unlinking an OAuth provider
  are refused the same way, since a linked identity signs in later.

- **Revoke-all now reaches MCP OAuth tokens.** An operator's password change or
  reset and sign-out everywhere revoked panel refresh tokens only. A stolen
  operator MCP refresh token kept minting hour-long `op_mcp_access` tokens,
  write scope included, and an already-issued one kept working until it
  expired. Revoke-all now revokes the operator's MCP refresh tokens in the same
  transaction, and the operator MCP endpoint and introspection refuse an access
  token issued before `sessionsInvalidBefore`. End-user MCP access tokens get
  the same check at the MCP endpoint, `/oauth/userinfo` and `/oauth/introspect`.
  A single-session revoke still leaves MCP connections alone.

- **A free plan's credits or licence can be claimed once per person, not once
  per organization.** `POST /billing/subscribe` reactivated a cancelled free-tier
  subscription for whatever beneficiary it was given, and credits and licences
  are keyed per beneficiary. A signed-in user could cancel, create an
  organization, activate again, and collect the plan's credits (or a new pooled
  licence) once per organization, with no limit. A free plan that materialises
  CREDIT or LICENSE is now claimable once per end-user per Application, recorded
  in a new `free_tier_claims` table that cancellation does not touch; another
  beneficiary gets `409 BILLING_FREE_TIER_ALREADY_CLAIMED`. Reactivating for the
  same beneficiary still works and issues nothing new. FEATURE and USAGE free
  plans are unchanged. Existing self-serve activations are backfilled as claims.
  Credits already issued are not clawed back.

- **Erasure now removes the person's address from email and webhook logs.**
  `DELETE .../end-users/:euid?erasure=true` tombstoned the end-user row but left
  the address in `email_logs.to_address`, in the new `email_suppressions` list, in
  the payloads of past `webhook_deliveries` (`user.created`, `password.changed`
  and others carry `email`) and in stored inbound billing receipts. The operator
  console kept showing it, and the new log archive would have uploaded it when the
  rows aged out.

  Erasure now tombstones the recipient, subject and any copy of the address in the
  error on the person's email log rows; deletes their suppression rows; rewrites
  `email`, device `fingerprint`, `metadata`, `label` and `description` in delivery
  payloads matched by the end-user id; and replaces the address in inbound
  receipts for that Application. Log and delivery rows, their status and counts,
  are kept. Nothing in another Application is touched. A new
  `(application_id, to_address)` index on `email_logs` keeps the update off a scan
  of the whole send history, and the erasure transaction timeout is now 60
  seconds. `docs/data-erasure.md` states what happens to rows archived before an
  erasure (they keep what they had; give the bucket its own retention).

- **A day-0 trialist is stored `TRIALING`, not `ACTIVE`.** `applyCheckoutCompleted`
  hard-wrote `ACTIVE` and never wrote `trialEndsAt`. Stripe's
  `customer.subscription.created` is not translated, and for a plain trial no
  `customer.subscription.updated` arrives until conversion, so the row read
  `ACTIVE` for the whole trial. Reported MRR sums `plan.amount` over
  `status: 'ACTIVE'`, so a 30-day trial on a $99 plan added **$99 of MRR on day
  zero against zero cash**.

  Both statuses are entitling, so what a trialist can DO is unchanged. What
  changes is that unpaid revenue stops being reported as revenue, and `TRIALING`
  (the status the Stripe mapper emits and the provider-switch refusal wording
  branches on) becomes reachable for hosted checkout again.

  `trialEndsAt` is now written from the days actually sent to the provider,
  taken from the trial ledger added in this release.

- **A seat override now reaches the licence.** `PATCH .../entitlement-overrides`
  with a SEATS quantity returned 200, echoed the new number and emitted
  `subscription.entitlements_updated`, while the licence went on refusing
  activations past the count it was issued with. The buyer paid for the seats and
  did not get them, and nothing reported the disagreement. Listed as a known issue
  in the 2.2.0 notes until now.

  The ceiling is reconciled from the write path, since that path never provisions
  and a provider-less subscription would otherwise never re-provision at all. It
  is computed as the **maximum** across every entitling subscription funding the
  pool, so one owner's renewal cannot lower a ceiling negotiated on another's:
  two owners can each hold a subscription on the same plan for the same
  organization, and both fund one pooled licence.

  Lowering a count does **not** revoke activations already in use. The next
  activation past the new ceiling is refused instead.

  `License` gains `entitlementKey`, defaulting to the empty string that existing
  rows and single-licence plans already use. A plan may carry more than one
  LICENSE entitlement, and both used to resolve to the same licence row, so
  whichever provisioned last silently overwrote the other.

- **A credit idempotency key is now scoped to the buyer.** `CreditLedger` was
  unique on `(applicationId, idempotencyKey)`, so a client-supplied key was
  treated as globally unique within the Application. But such a key names what
  is being paid **for**, not who is paying. The schema's own documented example
  is a lead id, and a lead identifies a lead, not a buyer.

  Two end users drawing down for the same lead sent the same key. The second
  found the first's ledger row, was told `applied: false`, **consumed for free**,
  and was handed another subject's `balanceAfter`. A shared work pool makes this
  the common path rather than an edge case.

  The uniqueness is now `(applicationId, subjectKey, idempotencyKey)`, matching
  what `UsageRecord` already does. Retries within one subject stay idempotent,
  which is what the key is for. **No action needed on upgrade:** widening a
  unique tuple can only be satisfied by data that already satisfied the narrower
  one, so the migration cannot fail on existing rows.

- **Buying the same one-off plan twice now delivers twice.** It charged twice
  and delivered once, permanently. The idempotency anchor for a provisioned
  purchase was the billing period, and a one-off plan (a credit pack, a
  perpetual licence) never gets a `currentPeriodEnd`, so both branches of that
  expression resolved to the same constant forever. The checkout upsert reuses
  one `(applicationId, endUserId, planId)` row, so the subscription id did not
  change either: the second purchase computed a ledger key identical to the
  first, the credit ledger saw the prior entry and applied nothing, and the
  balance never moved while a second SUCCEEDED payment was recorded.

  Nothing reported it. The webhook answered 200 and provisioning logged success.

  A one-off plan now anchors on the purchase (the provider checkout session)
  rather than on a period it does not have. Recurring plans are unchanged and
  still anchor on the period, which is what makes a renewal refill exactly once.

  This also changes a second case that was previously asserted as correct: a
  buyer who opens two checkout tabs for the same one-off plan and pays on both
  now receives both packs. It used to record both payments and grant one, so the
  operator kept the second charge and nothing surfaced the duplicate, and the
  payment is attributed to a subscription, so it never reached the
  unapplied-payment queue either. Two charges are two purchases; a buyer who did
  not mean to pay twice is made whole by refunding one.

  **If you sell credit packs, buyers who repurchased were charged and not
  credited.** Their payments are all recorded, so the affected purchases can be
  found by looking for more SUCCEEDED payments against a one-off plan than
  positive credit-ledger entries for the same buyer.

- **A personal subscription lookup no longer returns an organization's.**
  `endUserId` is required on every subscription, so an org-beneficiary row also
  carries the buying member's personal id. Two lookups filtered on `endUserId`
  alone and sorted newest-first, so an org purchase shadowed the buyer's own
  subscription: `getCurrentSubscription` (which callers cancel the result of, so
  a member cancelling their own plan could cancel the organization's) and the
  end-user MCP `get_subscription` tool, whose description says it answers for
  the signed-in user.

  Only affects deployments using org-beneficiary subscriptions where one end
  user both holds a personal subscription and is the buyer of record for an org:
  the ordinary shape of a team plan bought by its admin. The org-scoped
  lookups are unchanged.

- **Device security events are shown.** They were written with an application
  id but no workspace id, and the read path filtered on workspace, so the whole
  device audit trail was recorded and displayed nowhere.

- **An open-ended subscription grant cancels at period end.** With no period to
  schedule against, cancelling one ended it immediately.

- **The panel shows a change as soon as you save it.** Server actions
  redirected to the page the router had already cached, so the success banner
  appeared while the data did not move. Every mutation now invalidates the
  cache.

- **OIDC sign-in is bound to the issuer you configured.** A discovery document
  naming a different issuer is refused, ID tokens are checked for subject,
  issuer, audience and expiry (`OAUTH_ID_TOKEN_INVALID`), and discovery, token
  and userinfo requests connect only to addresses the SSRF guard approved.

- **End-user sign-in no longer reveals which accounts exist through response
  time.** Unknown addresses and password-less accounts are checked against a
  decoy hash, as operator sign-in already was.

- **The 16 KB metadata ceiling applies to every writer**, including
  organizations, plans, coupons, usage records, credit drawdowns and licences.
  Some of these were unbounded.

- **The super-admin plan-create route refuses a field it does not implement.**
  `POST /api/v1/admin/applications/:id/plans` builds SUBSCRIPTION plans, and its
  body schema silently dropped everything else. A caller asking for a LICENSE,
  USAGE or CREDIT plan got `201` and a subscription:
  `rekey plans create --kind LICENSE --credits-amount 500` reported success and
  created the wrong plan, with nothing to reveal it short of reading the
  pricing page. That body is closed now, so an unrecognised key, or a typo like
  `intervall`, answers `400 VALIDATION_ERROR` naming the field. Per-kind plans
  and `trialDays` are created on the tenant route,
  `POST /api/v1/tenant/applications/:id/plans`, which implements them.

- **The CLI no longer offers plan flags that cannot work.** `--kind`,
  `--license-kind`, `--license-duration-days`, `--license-seats-allowed`,
  `--meter-slug`, `--price-per-unit-cents` and `--credits-amount` are gone from
  `rekey plans create --help` and refused with `CLI_PLANS_KIND_UNSUPPORTED`,
  which names the panel and the tenant route, so a script written against the
  old contract gets an explanation rather than a wrong plan. Separately,
  **`rekey apps create` gains `--environment`** (`PRODUCTION`, `STAGING` or
  `DEVELOPMENT`): the admin route has always accepted the field and the CLI
  never sent it, so every Application it created was `DEVELOPMENT` with an
  `rp_test_` key.

- **Org-role changes now reach every API replica straight away.** The
  organization-role catalog cache never actually subscribed to its Redis
  invalidation channel: its only SUBSCRIBE was sent while the connection was
  still opening and was refused, so a role edit on one replica reached the
  others only when their 5 second cache expired. It now subscribes on every
  connect and reconnect, and serves nothing from cache while it is not
  subscribed.
- **One operator no longer rate-limits the whole panel.** The global limiter
  keyed operator requests on the IP they arrived from, which in Docker is the
  panel container for every operator, so the whole team shared 100 requests a
  minute and a single operator hit `429` within a few pages. It now keeps one
  bucket per secret API key (6000/min, `RATE_LIMIT_API_KEY_MAX`), per operator
  or signed-in end user (600/min, `RATE_LIMIT_AUTHENTICATED_MAX`), and only
  falls back to the client IP (100/min, `RATE_LIMIT_MAX`, unchanged) when the
  request has no identity. See [docs/rate-limits.md](docs/rate-limits.md).
- **Operator sign-in limits key on the operator's real IP.** Both self-host
  compose files now trust the panel's and portal's forwarded client IP, and
  only theirs: the two containers sit at fixed addresses on a private
  `rekey-edge` network (`REKEY_EDGE_SUBNET`, `REKEY_PANEL_EDGE_IP`,
  `REKEY_PORTAL_EDGE_IP` if the default subnet collides). Before, one person
  spraying the login page could exhaust the panel's shared bucket and lock
  every operator out. Sign-in limits themselves are unchanged. If you expose
  the panel with no proxy in front of it, set `TRUSTED_PROXIES=false`.
- **The API recognises your proxy by a shared secret.** Traefik sends
  `API_PROXY_SECRET` as `X-Rekey-Proxy-Secret` (a label in the compose files),
  and only then is `X-Forwarded-For` believed (`API_PROXY_HOPS` from the right;
  2 with a CDN that Traefik trusts). Without it, traffic through an
  unidentified proxy is never blocked by IP and falls back to per-key,
  per-account and per-Application limits; the API logs this at boot. Required
  in `docker-compose.api.yml`, which no longer trusts a hop count.
- **The hosted portal forwards the visitor's address** only for requests that
  carry `PORTAL_PROXY_SECRET` (added by Traefik) with
  `PORTAL_TRUSTED_PROXY_HOPS` set, so the API's per-IP limits count visitors
  rather than the portal. Its API calls now time out after 10 seconds.
- **Change password and the passkey step-up are credential routes.** They cap
  at 10 per minute per account and address, and a wrong password on either
  counts toward the account lockout like a failed sign-in. End-user sign-up caps at 10
  per minute per Application and address, `send-verification` at 10.
- **Rejected credentials are counted.** More than 100 401s a minute from one
  client IP (`RATE_LIMIT_AUTH_FAILURE_MAX`) refuses the address before any
  credential is checked, except for a verified secret key, and all operators
  and end users seen from one IP share a 3000/min ceiling
  (`RATE_LIMIT_AUTHENTICATED_IP_MAX`). Both apply only to an address that is
  the client's, never to a shared proxy.
- **`INTERNAL_CALLER_SECRET`** lets the panel and portal prove their calls
  when they reach the API through its public origin (the split
  deployment units): they send it as `X-Rekey-Caller-Secret`, and the API believes the
  one visitor address they send as `X-Rekey-Client-Ip`. Required in `docker-compose.api.yml`,
  `docker-compose.panel.yml` and `docker-compose.portal.yml`; see the Cloud
  checklist and admin-lockout recovery in DEPLOY.md.
- A blocked address is refused before any lookup except for a secret key that
  has already verified and is not revoked; the client address is decided before the first log
  line, and forwarded host and scheme are ignored unless the proxy sent them.
- `RATE_LIMIT_USAGE_MAX` now defaults to the per-key budget (6000) instead of
  1000.
- `POST /api/v1/tenant/auth/refresh` has its own per-IP bucket (60/min,
  `RATE_LIMIT_REFRESH_MAX`), and `GET /api/v1/portal/config/:slug` keys on the
  slug and client IP, with a per-IP ceiling across slugs.

- **The operator panel no longer freezes or errors when switching tabs under
  load.** Links no longer prefetch (21 background server renders on the first
  load of an end-user page, and one `GET /applications/:id` per card on
  `/applications`), and the end-user tabs, email sub-tabs and account pages
  show a loading skeleton on every switch. With prefetching on, 5 of 27 tab
  clicks in a headless run never committed; with it off, none did.
- **A save refreshes stale pages exactly once.** The refresh after a server
  action is tied to that action, fires even if the operator has already moved
  to another tab, waits until nothing on the landing page is still loading,
  and is fired again when Next discards it, at most three times in all. It could previously be
  discarded, leaving a pre-save render on the next tab.
- **A busy API is shown as busy.** A 429 or 503 now renders "The Rekey API is
  busy" with a countdown and a bounded automatic retry that honours
  Retry-After, instead of "Something went wrong" or an empty list that looked
  like real data. A 429 on the session refresh no longer signs the operator
  out. (A 503 there still does: the API may already have spent the token.)
- The authed layout fetches the operator and the workspace creation mode in
  parallel, and caches the creation mode for five minutes.

### Security

- **Credential routes cap their own request body** before it is read or
  rate-limited. See **Breaking changes** for the `413 PAYLOAD_TOO_LARGE` it
  answers.

- **The panel no longer forwards a client-chosen IP to the API.** With no
  proxy in front, a browser's own `X-Forwarded-For` became the address the
  API rate-limits operator sign-in and token refresh on. New
  `PANEL_TRUSTED_PROXIES` (default `0`) says how many proxies to believe, and
  the header is believed only when the proxy also presents
  `PANEL_PROXY_SECRET` as `X-Rekey-Proxy-Secret`, so a sibling container or a
  published port cannot pick the address either. Otherwise the panel reports
  the connection's address. **`docker-compose.prod.yml` now requires
  `PANEL_PROXY_SECRET`** and wires the Traefik header; the hosted panel
  compose uses 2 hops (Cloudflare, then Traefik).
- **The panel can prove its forwarded client IP to the API.** With
  `INTERNAL_CALLER_SECRET` set, every server-side panel call to the API carries
  it as `X-Rekey-Caller-Secret`, with the visitor's address in
  `X-Rekey-Client-Ip` when the panel validated one (omitted otherwise, never
  the panel's own address). The API can then believe it even when the call
  goes through a public origin. Optional; the panel and the API must share the
  value.

### Removed

- **A FEATURE override of `""` is refused** with
  `ENTITLEMENT_OVERRIDE_INVALID`. An empty string survives parsing but every
  `if (features.x)` gate reads it as absent, and a plan cannot carry one, so it
  was the one shape this module accepted while the resolver ignored it. Send
  `null` to remove an override.

- **A quantity on a licence with no seats is refused** with
  `ENTITLEMENT_OVERRIDE_INVALID`, where it was previously accepted. A licence
  quantity is its seat count; on a PERPETUAL or TIMED row no Rekey code reads it,
  so it was stored, reported sold, and delivered nowhere.

  It **is** on the wire (`quantity` appears in the resolved entitlement array
  and in `subscription.*` webhook payloads), so if you provision against it for a
  non-SEATS licence, that write path is now closed and the plan's `licenseKind`
  needs to be SEATS. Removing such an override still works: `null` deletes.

## 2.1.0

A minor release. Additive: nothing that worked in 2.0.0 stops working. Four
things to know before you take it:

- **TypeScript.** `OrganizationRole` widens from a three-value union to
  `string`, so an exhaustive `switch` over it now needs a default case. The
  three-value type is still exported, as `OrganizationBaseRole`.
- **Billing credential edits.** `PUT /billing-credentials/:provider` now
  distinguishes an ABSENT field from a field sent as an empty string. Absent
  keeps the stored value; empty still clears it. If you were relying on a
  partial body to blank the fields it omitted, it no longer does that.
- **Free trials are held.** A plan cannot carry `trialDays` in this release: a
  non-zero value is refused with `PLAN_TRIAL_UNAVAILABLE`, on the REST routes and
  through MCP alike. The field became writable during this cycle and two release
  reviews then found two ways it loses money, so it is held rather than shipped:
  a trial can currently be taken repeatedly by the same buyer, and a plan
  carrying both a trial and a CREDIT or LICENSE entitlement hands those over
  before the first payment. Setting it to `0`, and creating plans without it,
  are unaffected. `docs/specs/trial-eligibility.md` tracks what has to exist
  first.

- **Webhook event names.** `subscription.entitlements_updated` joins the
  catalog. `WebhookEventType` is an open string union so it keeps compiling, but
  an exhaustive `switch` over `z.infer<typeof WebhookEventTypeSchema>` gains a
  case. Nothing is delivered to an endpoint that has not subscribed to it.

### Added

- **An application can be promoted to production, and frozen when you would
  rather delete it.** `environment` stops being write-once: `POST
  /tenant/applications/:id/promote` moves DEVELOPMENT or STAGING to PRODUCTION,
  one way and once. Keys minted beforehand keep their `rp_test_` prefix and keep
  working, because the prefix is a label rather than a gate; revoking them would
  break an integration at the moment it went live.

  `POST /tenant/applications/:id/disable` is the reversible freeze standing in
  for the delete Rekey does not have. A disabled application refuses all
  end-user traffic at both API-key middlewares, serves no hosted portal, and
  dispatches no webhook, transactional email or dunning escalation, while every
  operator surface stays readable, because a freeze you cannot see into is a
  freeze you cannot undo. Nothing is deleted and `tokenGeneration` is untouched,
  so a token issued before the freeze still works after it.

  The two share one invariant: PRODUCTION-and-not-disabled applications must
  never exceed `maxProductionApps`. A disabled production application stops
  counting, which is what makes disable a usable substitute for delete, and
  re-enabling one consumes a slot and can be refused. All three doors into that
  count take a per-workspace advisory lock, because check-then-act let
  concurrent creates walk past the limit.

- **Per-subscription entitlement overrides.** `PATCH
  /tenant/applications/:id/subscriptions/:subId/entitlement-overrides`
  (billing-write) writes `Subscription.entitlementOverrides` as a sparse merge,
  with `null` removing an override. This is how you sell one customer a bespoke
  deal without minting a plan for one buyer.

  `entitlementOverrides` decided what a subscription granted and was read in
  five places and written in none, so the documented remedy for a negotiated
  allowance required SQL against production.

  Overrides merge over the plan's rows on every resolve, so FEATURE flags and
  USAGE allowances change at once. **Already-materialised grants do not
  backdate**: CREDIT is granted once per period against an idempotency anchor,
  so raising an allowance mid-period applies at the next renewal, and a licence
  already issued keeps the `seatsAllowed` it was issued with.

  New webhook event `subscription.entitlements_updated`, emitted only when the
  resolved entitlements actually change.

- **The applications list says which applications are not serving anyone.**
  Every row rendered identically apart from its environment, so "which of these
  is actually live?" meant opening each one. Rows now carry a badge when an
  application is disabled, when nobody can sign in (no auth method and no OAuth
  provider), or when a hosted portal is pinned to a DNS-unverified custom
  domain. Configuration is deliberately not badged: `invite_only` and
  `secret_only` are postures a whole workspace adopts, and badging them would
  chip every row while distinguishing none.

- **Organization roles are now a per-Application catalog, not a fixed enum.**
  `OWNER` / `ADMIN` / `MEMBER` are still there, seeded on every Application and
  undeletable, but an operator can now define their own names against a base tier:

  ```json
  { "name": "content-manager", "baseRole": "MEMBER", "description": "Drafts and edits content" }
  ```

  Rekey enforces the **tier**, never the name. A `content-manager` on tier
  MEMBER can do exactly what MEMBER can; the `canManage` ladder, the last-OWNER
  guard and the org-scoped billing writes all read `baseRole`. What the name
  means beyond that is your application's business.

  Authoring the catalog is operator-only (panel → Application → Organizations,
  `POST /tenant/applications/:id/organization-roles`, or the new
  `create_organization_role` / `update_organization_role` /
  `delete_organization_role` MCP tools; requires
  `authConfig.organizationsEnabled`). **Assigning** a role is unchanged and
  still belongs to the organization: an OWNER/ADMIN does it with their own
  end-user token via `PATCH /users/me/organizations/:id/members/:euid`.

- `GET /api/v1/users/me/organizations/roles` returns the assignable role names on
  the end-user credential, so an org-admin UI can populate a role picker.
  `rekey.organizations.listRoles()` in the Node SDK.

- `GET /api/v1/users/me/` and `GET /api/v1/auth/me` now also return
  `activeOrganizationRole` and `activeOrganizationBaseRole`. Both are null with
  no active organization, or when membership lapsed since the token was minted.

- An invitation may now **omit** `role`, in which case it lands on the
  Application's default organization role.

- Organization-role changes are audited: `app.organization_role_created`,
  `app.organization_role_updated`, `app.organization_role_deleted`.

- `ORGANIZATION_ROLE_RETIER_ORPHANS_OWNERS` (409) refuses moving a custom role
  off the OWNER tier while some organization's only owner holds it. A re-tier
  changes no membership row, so the per-member last-owner guard never runs;
  without this an operator could leave organizations with nobody able to manage
  members or authorize a charge.

### Fixed

- **An entitlement override could not un-cap a metered plan.** Quantity was
  validated with `Number.isFinite` and nothing else, so four values stored
  happily and then did the wrong thing at resolve time.

  The worst was a USAGE quota of `0`. Included quota only counts as a cap when
  the quantity is above zero **or** the row carries a `creditsPerUnit`; an
  unpriced hard cap overridden to `0`, which reads as "no free units", has
  neither, so the resolver reported the meter as unmetered. Tightening a plan
  removed its limit.

  Also refused now: a negative quantity, which did the same; a fraction or a
  value past 2147483647, which stored here and then threw against the 32-bit
  column inside provisioning at the *next* renewal, surfacing weeks later as a
  retrying 500 on the renewal webhook; and a CREDIT quantity of `0`, which did
  not mean "no credits this period" but "the grant never runs".

  Every one of these was already refused for a *plan*. The override path now
  delegates to the same per-kind validator rather than restating its rules, so
  the two cannot drift again.

- **Four error strings sent operators into a refusal.** A registered plan's
  price is immutable, so changing it means archiving the plan and creating a
  replacement. `PLAN_PRICE_IMMUTABLE`'s fix, the MCP `set_plan_active` and
  `update_plan` tool descriptions all said exactly that and none of them
  mentioned that the replacement needs a **different slug**, because archiving
  deliberately does not release the old one. Following any of them literally
  ended in `PLAN_SLUG_TAKEN`. An MCP agent following the tool text hit it every
  time.

  All four now name the slug requirement. `PLAN_SLUG_TAKEN`'s own archived-case
  fix was half wrong in the other direction: it offered "reactivate and edit it
  in place", which cannot change a price on a registered plan, which is the
  usual reason for wanting a replacement. It now says so.

- **A modal no longer discards what you typed when you click outside it.**
  Every panel dialog closed on any backdrop click, so a stray click next to the
  card threw away a half-filled form with no warning and no undo. A dialog you
  have started filling in now ignores the backdrop, and Esc asks before
  discarding. An untouched dialog still closes on both, and the close button
  always closes.

- **Editing a billing provider no longer forces you to retype its secret.**
  Stored credentials are encrypted and never sent to the panel, so every input
  in the edit dialog renders empty; submitting it overwrote each stored value
  with an empty string and was rejected, which meant changing a webhook URL
  required fetching the API key from the provider dashboard again. The dialog
  said "leaving a secret blank keeps the existing value" and could not do it.

  Now it can. A field omitted from the request keeps its stored value; a field
  sent as an explicit empty string is still cleared. First-time configuration
  still requires every field, a supplied key still rotates, and a
  carried-forward value still faces the provider's pattern check.

  Two consequences worth naming, both of which the first attempt at this got
  wrong. A partial edit no longer relabels the account's mode: for a provider
  whose keys do not state their environment (PayPal), an edit that omitted
  `mode` would have marked a live account as sandbox and pointed every call at
  the sandbox host. And non-secret fields are carried forward too, so a partial
  edit no longer drops PayPal's `webhookId` and 503s every inbound webhook.

- Two concurrent member changes can no longer both pass the last-owner guard.
  The guard counted owners and then wrote, so two demotions racing on an
  organization with exactly two owners both saw two, both passed, and both
  committed, leaving zero. Guard and write now run in one transaction behind a
  row lock.

### Performance

- The organization-role catalog is cached per Application on a short TTL, with
  every write invalidating it after commit. It was read on nearly every
  org-scoped request despite being operator-authored and near-immutable.
  Membership rows are not cached, so a role change for a PERSON still takes
  effect immediately.

### Changed

- **The panel, portal and admin test suites now run in CI.** All three have had
  a vitest suite for as long as they have existed and none of them had ever run:
  the filter listed only `packages/*` and two apps, and the job was gated on
  areas a front-end change does not touch, so such a pull request showed
  `Test: SKIPPED` beside a green `CI: SUCCESS`. Nineteen files were enforced by
  whoever remembered to run them locally. Self-hosters running the workflow get
  the same coverage.

- **`OrganizationMembership.role` and `OrganizationInvitation.role` are now
  strings**, holding a catalog name. Every previously valid value still spells
  the same and still works, so no stored data changes. TypeScript consumers that
  switched exhaustively over the old
  `OrganizationRole = 'OWNER' | 'ADMIN' | 'MEMBER'` union will now see a
  `string`: handle the default case. The old union is still exported, as
  `OrganizationBaseRole`, and remains the right type for the tier.

- The end-user role catalog is renamed **application role**. The model is
  `ApplicationRole`, the table `application_roles`, and the operator route
  `/tenant/applications/:id/application-roles`. The old `/end-user-roles` path
  still works. The public `EndUser.role` field and the `END_USER_ROLE_*` error
  codes are **unchanged**: they are wire contract.

  The rename exists because there are now two role axes and the old name said
  nothing about which one it governed. `EndUser.role` is app-wide: one value
  per (Application, end-user), identical in every organization that user belongs
  to, and Rekey never acts on it. `OrganizationMembership.role` is per
  (organization, end-user), and is the one Rekey enforces. See
  [docs/auth.md → Roles](docs/auth.md#roles-two-axes-and-which-one-you-want).

## 2.0.0

The first stable release. The release-candidate series ends here; `latest` on
npm now points at a stable version rather than an RC.

Everything below landed after rc.9. Two of them are behaviour changes an
existing rc integration can notice, and they are listed first for that reason.

### Changed

- **An Application now bills individuals OR organizations, never both.**
  `billingConfig.billingSubject` already decided this, but only one direction
  was enforced: an org-subject Application refused a checkout with no
  organization, while a user-subject one quietly ACCEPTED an `organizationId`
  and wrote an org-billed subscription beside the personal ones. Because
  `Subscription` is unique on `(applicationId, endUserId, planId)` with no
  beneficiary in the key, those two rows are one row, and whichever arrived
  second silently replaced the other while the subscription it replaced kept
  billing at its processor. A user-subject checkout that names an organization
  is now refused with `BILLING_ORGANIZATION_NOT_ACCEPTED`.

  If you were passing `organizationId` into a user-subject Application, switch
  that Application to `billingSubject: 'org'` or drop the parameter. Changing
  the subject is refused while subscriptions of the other subject are live
  (`BILLING_SUBJECT_CHANGE_BLOCKED`), because flipping it would strand them.

- **A succeeded Razorpay payment that matches no local subscription is now
  recorded.** It used to be dropped entirely: no `Payment` row, no log, no
  trace of the money anywhere. Operators running Razorpay should expect the
  first look at the new unapplied-payments queue to surface historical charges
  that were previously invisible.

### Added

- **Refunds, across Stripe, PayPal and Razorpay.** `refundPayment` on the
  provider interface, paired with a `capabilities.refunds` declaration so a
  provider that cannot refund says so rather than failing when an operator
  presses the button. Partial refunds are supported everywhere. Note that no
  provider returns its fee on a refund, so a refund costs the operator the
  original processing fee.

- **Unapplied payments: a queue for money that arrived for something Rekey
  never applied.** Usually a checkout that completed at the provider after
  Rekey stopped waiting for it, which means the customer most likely paid for
  something they expect to receive. Rekey never refunds these automatically;
  the operator decides between refunding, keeping the money and extending the
  customer's access, or closing the case with a note. Ordered oldest first,
  because refund windows close (PayPal at 180 days, Razorpay at six months)
  while card-network dispute windows stay open. Panel → Application →
  Unapplied, plus `GET/POST /api/v1/tenant/applications/:id/unapplied-payments`.

### Fixed

- **Concurrent checkouts could reach two processors.** The provider binding was
  read-then-act with nothing holding the two together, so two overlapping
  checkouts by one buyer both read a state in which nothing bound them and both
  proceeded. Measured: two concurrent checkouts on different plans naming
  different processors both returned 200 and left the buyer billable by Stripe
  AND PayPal. The binding decision is now serialised per buyer. The
  second-completion guard had the same shape and is now stated as a write
  predicate, so two completions arriving at once cannot both win.

- **Every Server Action answered 500 when the browser sent a malformed
  `Origin`.** Next reads the header with `new URL()` guarded only by a
  `typeof` check, and a browser sends the literal string `"null"` from an
  opaque origin — a sandboxed iframe, or a form POST that followed a
  cross-origin redirect. The action failed before any application code ran, so
  nothing saved, and because it never resolved the submit button sat on
  "Saving…" until the page was reloaded. Reported against the panel's OAuth
  provider form; it affected every Server Action in the panel, marketing,
  portal and admin.

- **The account funnel pages were never centred.** Sign-in, sign-up,
  forgot-password, reset, verify, account, error and not-found all capped their
  width without centring, so on any wide viewport the form sat against the left
  gutter.

- **The OAuth redirect URI field said the wrong thing.** It is the operator's
  own application callback, never a Rekey URL: the provider redirects the
  browser to their server, which then hands Rekey the code. The hint now says
  so, and warns that the provider compares the string byte for byte.

### Known issues

- Passkeys remain unavailable. The WebAuthn routes, SDK helpers and storage are
  built, but there is still no way to configure the relying party from the
  panel or the API, so no deployment can turn them on.
- A sign-in page cannot discover which OAuth providers an Application has
  configured; the consuming app must also carry the list in its own
  environment. Tracked as #463.

## 2.0.0-rc.9

Everything below was found the way rc.8 said it would have to be: by running
the product signed in, against a live deployment, and clicking. The suite was
green for every one of them.

Still a release candidate. The panel has not had a full signed-in click-through
of its mutation paths (see Known issues).

### Fixed

- **An API-key rejection now names the deployment that rejected it.** Keys
  belong to the deployment that minted them, and the commonest integration
  mistake is pointing half a configuration at one deployment and half at
  another — a server `REKEY_URL` still on localhost while the browser's
  `NEXT_PUBLIC_REKEY_URL` has moved to Cloud. The old message listed three
  states the key might be in and never mentioned the one thing that decides it:
  where it was checked. `API_KEY_INVALID` and `PUBLISHABLE_KEY_INVALID` now name
  the origin and say a key from another deployment is unknown here. Reported by
  a user who could not find which URL to use (rekey-dev/rekey#29), which the
  new `docs/api-url.md` answers.

- **Plans can be edited from the panel.** `plansService.update`, the REST
  `PATCH` and the `update_plan` MCP tool all shipped, so an agent could correct
  a plan while the operator who created it could not — the table offered only
  Entitlements, Archive and Reactivate. A plan created with a typo or a missing
  price had to be archived and re-created under a different slug. There is now
  an Edit action; it renames always, and edits the price only while no provider
  price exists, because a minted provider price is immutable and the API
  refuses to change it. That makes it the repair path for a plan whose
  registration FAILED. Reported as rekey-dev/rekey#30.

- **A duplicate-slug refusal says when the holder is an ARCHIVED plan.**
  Archiving flips `active` and keeps the row, so the slug stays reserved, and
  "a plan with that slug already exists" sent operators looking for a plan they
  believed they had removed. The message now says the existing plan is archived
  and the fix names both ways out: reactivate and edit it, or choose a
  different slug. The slug is deliberately not released — it is the public
  identifier integrations pass to checkout and read back off a subscription, so
  reusing it would silently change what an existing caller's `pro` means.

- **A buyer who already pays through one processor can no longer be checked
  out through another.** `Subscription.provider` is immutable for a row's
  lifetime, so a second checkout somewhere else never produced a changed
  subscription. It produced a SECOND one and two charges a month, with nothing
  in either processor's dashboard hinting at the other. The guard that existed
  keyed on (application, end-user, PLAN), so it only fired on re-buying the
  identical plan and missed the ordinary path: subscribed to `basic` through
  PayPal, upgrade to `pro`, get routed to Stripe. Checkout now resolves the
  processor the buyer is already on across the whole Application, before the
  geo router runs, and matches both the billing subject and the row the upsert
  will write (they are keyed differently, and an org checkout could otherwise
  rewrite a personal row's `provider` while leaving the other processor's
  subscription id on it). A `provider` that disagrees is refused with
  `BILLING_PROVIDER_SWITCH_BLOCKED`; a checkout that named none is pinned to
  the bound processor instead of being left to drift. One-off purchases
  neither bind nor are bound, since a credit pack or a perpetual licence is a
  single charge that creates no second billing relationship, while TIMED
  licences recur and stay guarded. A subscriber whose processor the operator
  has since disabled now gets `BILLING_BOUND_PROVIDER_UNAVAILABLE`, which
  names re-enabling it or cancel-then-rebuy rather than telling them to omit a
  `provider` they never sent.

- **Opening a checkout could move a live subscription to a different billing
  subject.** `beneficiaryOrgId` is not part of the `Subscription` uniqueness
  constraint, so a personal subscription to `pro` and an org-billed one to
  `pro` for the same buyer are one row. An owner of two organizations who
  opened a checkout for a plan the FIRST one already held rewrote that row:
  200 OK, still `ACTIVE`, still carrying the first organization's provider
  subscription id, now billed to the second. The first lost the entitlement,
  the second had it for free, the processor kept charging, and no payment was
  involved. A guard on the provider had been covering this by accident, and
  only when the geo router disagreed; pinning made the two agree by
  construction and removed the cover, so the refusal is now stated —
  `BILLING_SUBSCRIPTION_SUBJECT_CONFLICT`. It protects a LIVE subscription
  only: a checkout nobody completed can still change subject, because refusing
  there would tell a buyer to cancel something that does not exist.

- **Two checkout sessions on one subscription could both be completed.** One
  row deliberately carries several completable sessions, and checkout
  deliberately lets the second be opened at a different processor — but
  nothing stopped both being paid. `applyCheckoutCompleted` had no
  second-completion check and `ACTIVE → ACTIVE` is an allowed transition, so
  the second completion overwrote `providerSubId` and the first provider-side
  subscription became unreachable: cancel could not find it and it billed
  forever. The second completion is now refused, and the subscription it
  created is recorded on the row (`metadata.unappliedCompletions`) so an
  operator can find and cancel it. Relatedly, `Subscription.provider` is
  written by checkout before anybody has paid, so a buyer who opened Stripe,
  went back, opened PayPal and then paid at Stripe left the row naming one
  processor and carrying the other's id — which is the column `cancel` dials.
  The completion now stamps the processor whose session actually completed.

- **A provider-binding refusal could name a provider nobody can use, or tell a
  buyer to cancel something they never bought.** Three faults in the wording
  and ordering of the two refusals above, all reachable by an ordinary buyer.
  Landed after `v2.0.0-rc.9` was tagged, so the published rc.9 does not carry
  them; they ship with whatever release follows it.

  The switch refusal was decided before the router had looked at availability,
  so a subscriber bound to a DISABLED provider who named another one was told
  to check out through the disabled one. Availability is now settled first,
  whether or not the caller named a provider.

  Its remedy did not distinguish credentials that are disabled from
  credentials that were deleted. Cancellation dials the processor, so with the
  credentials gone a buyer whose row carries a provider subscription id can
  neither buy nor cancel; a checkout nobody finished carries no such id and
  stays cancellable either way. The two states now get different instructions.

  And a started checkout binds just as a subscription does, but was described
  as one ("already pays", "cancel the existing subscription") to somebody who
  had never paid. Both refusals now name an unfinished checkout for what it is,
  and say that no completed payment has been RECORDED rather than that nothing
  was charged, because a paid checkout whose webhook was lost sits in exactly
  that state. That wording is keyed on the row having no provider subscription
  id as well as being PENDING: Stripe's `paused`, and every status the codebase
  does not recognise, also map to PENDING while keeping the id, and those rows
  are paid. A trial gets its own wording on both refusals for the same reason.
  The portal and the marketing checkout repeated the assumption and were
  corrected.

  Two consequences worth knowing. A checkout by a buyer bound to an Application
  with no enabled providers at all now answers `409
  BILLING_BOUND_PROVIDER_UNAVAILABLE`, naming the provider that buyer actually
  uses, where it previously answered `400
  BILLING_CREDENTIALS_NOT_CONFIGURED`, which named whichever provider
  `billingConfig` mentioned and could be one the buyer has no relationship
  with. And the unfinished-checkout remedy deliberately quotes no duration: the
  24-hour window is measured from `updatedAt`, so re-opening the same checkout
  restarts it, and a number there would be a promise the code does not keep
  (rekey-dev/rekey#438).

- **A plan created before its billing provider was silently un-checkoutable.**
  Plans register with the provider at creation time, so a plan created before
  its Application had credentials has no price behind it, and connecting a
  provider afterwards does not reach back and repair it. Nothing surfaced that:
  the plan listed, it was `active`, `registrationStatus` read `NOT_REQUIRED`,
  and the first thing that disagreed was a buyer clicking Buy and getting a
  409. Providers now answer `planCheckoutBlocker()`, plans carry
  `checkout.ready`, the panel warns on both the Billing and Plans tabs, and
  `register_plan_with_provider` over MCP performs the repair.

- **`RekeyProvider` treated any failed session refetch as a signed-out user.**
  `catch { setUser(null) }` meant a CORS failure from a non-allowlisted origin
  flipped `useUser()`, `<SignedIn>` and `<SignedOut>` to signed-out the instant
  the page hydrated, on a page that server-rendered signed-in. The same shape
  turned any transient API outage into a fleet-wide sign-out. Only 401 and 403
  clear the user now.

- **The panel could hang on a blank page for five minutes.** No request carried
  a deadline, so a stalled fetch sat on undici's default. That is invisible on
  a page load and not invisible after a mutation, because Next renders `null`
  for the page subtree while a server action's redirect is in flight. Reads now
  time out at 15s, writes at 30s, the refresh exchange at 10s. A timed-out
  write reports that it MAY have applied, because we stopped listening, which
  is not the same as it not happening.

- **`SubscriptionDto` did not say which processor holds the subscription.** It
  carried `providerSubId` and not `provider`, which is an id belonging to
  nobody. `provider` is now on the DTO.

- **`ProvidersListDto` and `BillingProviderInfoDto` are exported from
  `@rekey.dev/node`.** `billing.getProviders()` returns the former and the
  package already imported it; the public export block omitted it.

- **`@rekey.dev/astro` no longer falls back to `https://api.rekey.dev` when
  `REKEY_URL` is unset — BREAKING.** The fallback did not fail: it sent every
  request this SDK makes to Rekey Cloud, carrying the deployment's
  `REKEY_SECRET` in an `Authorization` header, and on the session calls the
  **end-user's refresh token** (`auth.refresh`, `auth.signOut`) and **access
  token** (`getCurrentUser`) as well. The requests die there because the key is
  unknown at Rekey Cloud, so the only symptom was a puzzling 401 — by which
  point the credentials had left the operator's infrastructure.
  `@rekey.dev/node` and `@rekey.dev/nextjs` have always required the value; the
  three now agree, and the refusal names the Cloud, self-hosted and local
  answers. This SDK arrived after the pass that removed every other Rekey-owned
  default and reintroduced the pattern.

  **If you self-host with `@rekey.dev/astro` and never set `REKEY_URL`: set it,
  rotate that secret key, and treat any end-user session live during that
  period as disclosed.** `^2.0.0-rc.8` matches `2.0.0-rc.9`, so a lockfile
  refresh alone picks this up — deliberately, since it now fails loudly with
  the value it needs named in the message.

- **A self-hosted deployment's own `/docs` no longer advertises our API as a
  server.** The OpenAPI document hard-coded `https://api.rekey.dev` and
  `http://localhost:3030` in `servers`, which every deployment then served from
  its own Swagger UI. "Try it out" posts to the selected server, so an operator
  pasting their own key into their own docs page sent that credential to a host
  they never chose — the same shape as the astro fallback above, on a surface
  where the credential is typed in by hand. It now lists that deployment's own
  `API_URL`, and only that.

### Changed

- **The drop-in components look like Rekey.** Flat and editorial: 2px radii,
  squared badges and avatar, the user menu on a hairline instead of a drop
  shadow, uppercase letter-spaced badges, and the current plan marked with a
  solid rule rather than a tint.

- **Control radii no longer derive from the surface radius by subtraction.**
  A 2px surface radius made every input and button `calc(2px - 4px)`. Controls
  read `--rekey-radius-control`, exposed as
  `appearance.variables.borderRadiusControl`, which **defaults to whatever
  `borderRadius` is set to**. An integrator who set only `borderRadius` (the
  one knob the docs teach) keeps getting it applied to both, so upgrading
  changes nothing they did not ask for.

- **Every SDK now says which URL to use.** `apiUrl` / `REKEY_URL` is required
  and has no default, but the errors said only that it was required, and the
  READMEs showed `https://api.rekey.dev` labelled "Your Rekey deployment" —
  which reads as a placeholder, so a Rekey Cloud customer had nothing to go on.
  The missing-value errors in `@rekey.dev/node`, `@rekey.dev/nextjs` and
  `@rekey.dev/astro` now name the Cloud host, the self-hosted answer and the
  local one, and [docs/api-url.md](docs/api-url.md) covers it in full —
  including that Cloud is a single origin for every workspace, scoped by API
  key rather than by hostname, and how to verify an origin with `/health`
  before wiring any keys.
### Security

- **The four MCP read tools that live in the operator write-tool set enforce
  per-application grants.** `list_plans`, `list_plan_entitlements`,
  `list_usage_meters` and `list_api_keys` are declared next to the write tools
  but carry no `write`, `admin` or `minRole`, so the dispatcher's role gate does
  not apply to them — and they resolved their Application through a helper that
  checked the workspace and not the caller's grants. A workspace MEMBER with
  zero grants could read the plan and pricing catalogue, and API-key metadata,
  for every Application in the workspace. The REST routes enforce grants and so
  does the read-tool set; this file was the seam between them. No credential was
  exposed (the key hash is never returned) and nothing crossed a workspace
  boundary. The check is now applied to every app-targeted tool in the file, not
  only the reads, so a tool that grows a write later cannot forget to opt in.

### Known issues

- **A `Subscription`'s uniqueness constraint does not include
  `beneficiaryOrgId`**, so a personal subscription and an org-billed one for
  the same (end-user, plan) are the same row, and one buyer cannot hold one
  plan for two organizations at once. Checkout refuses rather than overwrites,
  but the refusal is the mitigation, not the fix: a checkout that is still
  PENDING can change subject, which means the earlier subject's session can
  still be paid after the row has moved. Flagged in `ORG_BILLING.md` §5,
  tracked as #431.

- **Fastify response schemas are documentation, not enforcement.** A route can
  emit fields its OpenAPI does not promise; `GET /billing/subscription` returns
  the raw row.

## 2.0.0-rc.8

The release that a security audit, an architecture review and three senior
review passes were spent on. Every fix below was found by reading the code
adversarially or by running it — none by the test suite, which was green
throughout.

Still a release candidate for one reason: nothing here has been exercised
against a live deployment by a real signed-in user. The starter kits shipped
green and were still broken; that lesson has not been paid for twice.

### Security

- **A publishable key could have us email a live login token to any domain.**
  The reset, magic-link and verification routes took a `{token}` URL template
  from the caller and rendered it into a link in a message we send — our
  branding, our SPF/DKIM — validated only for being parseable. The publishable
  key is public by design and served unauthenticated. So anyone could have us
  mail a victim a genuine, correctly-branded, deliverable email whose button
  carried a live single-use session token to a domain they controlled. One
  click was account takeover, and every signal a careful user checks said the
  mail was legitimate, because it was. `authConfig.redirectUrls` already
  existed for exactly this and was enforced nowhere; it is an origin allowlist
  now, failing closed.
- **Live auth tokens were being sent to Google Analytics.** The panel mounted
  analytics in the root layout with no `page_location` override, so GA4
  received the full URL — including `reset-password?token=`, `accept-invite`
  and `mfa-verify?challenge=`. The compose file also defaulted the measurement
  id to Rekey's own property on a self-hosted console.
- **An operator PAT ignored role demotion.** A token minted by an admin kept
  full workspace power after that person became a member, including minting
  Application secret keys — durable credentials outliving the token. Scopes
  bound what a token may do; they cannot say whether its holder is still
  allowed to do it.
- **The per-Application auth ceiling was per-IP, always.** It keyed on a field
  a parent hook could never see, so no aggregate per-Application cap existed:
  one password sprayed across many accounts from rotating IPs was bounded only
  per IP.

### Billing

- **A granted term never ended.** Entitlement resolution read status alone and
  nothing swept an elapsed period, so "comp this account for fourteen days" was
  a permanent grant. Shipped with a backfill, because making that column
  load-bearing retroactively would have expired every hand-provisioned
  subscription on deploy.
- **One webhook could rewrite every subscription in an application.** A payload
  with no subscription id collapsed the `where` clause; a single
  `subscription.deleted` cancelled the lot.
- **A chargeback opened a dunning case**, emailing the customer about the
  charge they were disputing.
- **A plan with two CREDIT entitlements granted one.** The buyer paid for 700
  credits and received 500.
- **PayPal money was wrong in both directions** for currencies with no minor
  unit: sales recorded at 100x, and plans registered at a hundredth of their
  price with a decimal point PayPal rejects outright.
- **Operator cancellation ended every provider-less subscription immediately**,
  mid-period, no refund — while the self-service path on the same row cancelled
  at period end.

### Added

- **Trials.** `Plan.trialDays`, `Subscription.trialEndsAt`, and `TRIALING`.
  Fail-closed per provider: a module must declare `capabilities.trials` before
  a plan carrying one can be checked out through it, because silently dropping
  a trial charges the buyer today for something the pricing page called free.

### Fixed

- Refresh, reset and verification tokens are pruned, with a thirty-day window
  so replay detection keeps working — deleting a revoked token immediately
  turns "this was rotated" into "unknown token".
- The panel no longer loses the whole console to one 403, and a decorative
  health probe can no longer hold it for five minutes.
- The OSS strip runs on every pull request. It had been broken on `main`,
  silently blocking every release.

### Performance

Email sends are bounded end to end, including the tenant-supplied SMTP path
where the per-phase timeouts allowed a 42-second stall. API-key auth is one
query, `lastUsedAt` writes are throttled, and the tenant filter moved into the
query itself rather than depending on every caller to remember it.

## 2.0.0-rc.7

A new package for Astro, and the session bug shipping it exposed in the Next.js
one.

Still a release candidate, and the reason is unchanged: everything below was
found by an adversarial review or by running the product, not by the test
suite. The `@rekey.dev/nextjs` fix in particular had been live since rc.6 and
passed every test that package has.

### Added

- **`@rekey.dev/astro`** — session handling for Astro 4 through 7: middleware
  that puts the session on `Astro.locals`, cookie helpers, sign-in and
  sign-out. The astro-starter kit carried ninety lines of this, and every other
  Astro app was going to write its own. Cookie names and lifetimes match
  `@rekey.dev/nextjs`, so moving an app between the two frameworks does not
  sign everybody out.

### Fixed

- **`@rekey.dev/nextjs` cleared the session for three of the six ways a refresh
  token can be dead.** `/auth/refresh` throws six terminal codes; the set held
  `EXPIRED`, `REUSED` and `USER_TOKEN_INVALID`. The two most common were
  missing: `REVOKED`, which is what "sign out my other devices" and an operator
  revoking a session produce, and `INVALID`, which is any stale cookie, a
  restored database or an app rebuilt from scratch. An unlisted code fell
  through to the "the API failed, keep the cookies" branch — correct for an
  outage, wrong for a token that is finished. The cookie was never cleared, so
  the browser re-presented a dead credential on every request for the next
  thirty days, each one a doomed round-trip, while the user looked at a
  signed-out page with no way to fix it. Both packages now match by prefix, so
  a seventh code added API-side cannot silently reopen it.
- **`USER_TOKEN_WRONG_APPLICATION` never reached the refresh path.** It is what
  a secret repointed at a different Application produces, or a second Rekey app
  writing `rekey_access` on a shared parent domain. Rethrowing left a cookie
  that could not be cleared.

### Changed

- **`clean-for-public.sh` runs on every pull request.** It used to run once per
  release, when a tag was pushed, which is the worst moment to learn it is
  broken — and in August it was, exiting 1 on `main` for a full day with the
  mirror publish and the npm release blocked behind it. Nobody noticed, because
  nothing else executed it.

## 2.0.0-rc.6

Metered billing, which is the half of "auth and billing" that was previously
only metering. Plus the SDK defects three starter kits found by being used.

Still a release candidate. Every fix below was found by running the product or
by an adversarial review, not by the test suite — six of them were in code that
typechecked, built and passed its own tests. That ratio is the reason this is
not the stable tag yet.

### Added

- **Usage past an included quota is charged against a prepaid credit balance.**
  A `USAGE` plan entitlement can carry `creditsPerUnit`; consumption beyond the
  included units draws down the subscriber's credits inside the same
  transaction that writes the usage record, so a unit recorded but not paid for
  cannot exist. A balance too low is refused with `402`, never billed into the
  negative. No payment provider is involved — the credit pack that funds the
  balance is an ordinary one-off charge.
- **`refreshSession()`** in `@rekey.dev/nextjs`, for route handlers and
  middleware that may persist a rotated session.
- **`<RekeyStyles>`** and a generated `@rekey.dev/react/styles.css`, for
  rendering the component stylesheet once or linking it as a file.
- **`isCancelScheduled(sub)`** in `@rekey.dev/shared-types`, for the question
  `cancelsAtPeriodEnd` was being misread as.
- **`docs/billing-architecture.md`** — the billing model, the decisions behind
  it and what they cost, and how postpaid billing and discount durations extend
  it without unpicking what is there.

### Fixed

- **`auth()` threw out of a render when the access token had expired.** It
  refreshes by writing cookies, which Next forbids during a render, so it did
  not return null — it threw. The access cookie lasts fifteen minutes and the
  refresh cookie thirty days, so every signed-in user hit this a quarter of an
  hour after signing in: a 500 on every route, including the sign-in page they
  would have used to recover. It now returns the refreshed session and persists
  it where it can.
- **A transient API failure signed users out.** The same function cleared both
  cookies on any refresh error, so a timeout destroyed the one credential that
  could have recovered the session. Only a verdict about the token itself
  clears it now.
- **`rekeyMiddleware` could protect the page it redirects to.** Supplying
  `publicRoutes` replaces the default list, so a caller who omitted their
  sign-in path — or named a custom `signInUrl` — got a redirect loop with
  nothing in the logs. `signInUrl` is exempt from the gate whatever the caller
  passes.
- **The React components had no styling when rendered server-only.** The
  stylesheet was injected from a client effect, so anywhere the components
  render without hydrating — Astro without a client directive — produced
  correct markup and no styling at all. It renders in the tree now.
- **`PricingTable` and `CheckoutButton` required a Next Server Action**, which
  made the billing components unusable in every other framework. They accept a
  URL as well.
- **Three ways entitlement resolution gave away paid quota.** Buying a credit
  pack deleted the free tier, because the fallback fired only at zero
  subscriptions and a one-off purchase creates one. A lapsed organization kept
  its included quota indefinitely. And `occurredAt` was unbounded, so usage
  could be backdated into a month whose quota was unspent.
- **Usage idempotency keys were not scoped to the subject**, so one subject's
  key returned another's record — on a priced meter, consumption nobody paid
  for.

### Changed

- **`cancelsAtPeriodEnd` is now `cancelEffect`**, returning `'period-end' |
  'immediate'`. It predicts what cancelling *now* would do; it never described
  a subscription already ending. Everyone who met it read it as the latter,
  including three of our own starter kits, which hid the cancel control from
  every healthy subscriber and mislabelled it for `PAST_DUE` ones. A
  discriminant cannot be mistaken for a state. Use `isCancelScheduled` for the
  state question.

## 2.0.0-rc.5

Everything here came out of running the product rather than from the test
suite, which is the reason this is a release candidate and not the stable tag.

### Fixed

- **The sign-in redirect was blocked by our own Content-Security-Policy.** The
  page is served by the API and its job is to redirect to the relying party on
  another origin; `form-action 'self'` forbade exactly that. Browsers enforce
  that directive across the redirect a form submission triggers, so the server
  issued a correct 302 and the browser silently declined to follow it. The same
  header blocked the Application's logo and the script that acknowledges a
  click. The page now sends its own policy. No headless client enforces CSP, so
  nothing in the suite could have caught this.
- **A declined payment said "an unexpected error occurred".** Every PayPal
  failure threw a plain Error and reached the caller as a generic 500. Seven
  call sites now answer `BILLING_PROVIDER_REFUSED` with PayPal's own error
  name, so a declined card says so. The provider's free text stays in the
  server log because it can name the account.
- **A refused OIDC token exchange said nothing.** Same shape: the issuer states
  the reason in a fixed vocabulary and it was discarded.
- **"Something went wrong" replaced accurate quota messages.** Creating a second
  production Application at the limit hid a message that named the limit and
  the current count. The panel now shows the API's own words when it has none
  better.
- **An OAuth sign-up the provider would not vouch for could not be recovered.**
  The account was created and every sign-in refused, with nothing sent that
  would let the person prove the address.
- **Five different sign-in failures answered one error code**, so a bug, a
  stale link and a forged callback were indistinguishable.

### Added

- **PKCE on the generic OIDC provider**, decided by the issuer's discovery
  document. Without it no issuer that mandates PKCE could be used at all, which
  includes Rekey's own Applications. The verifier is held server-side against
  the CSRF state and never given to the browser.
- **OAuth clients tab**: list what has registered against an Application,
  revoke it, and close open registration. Registration is unauthenticated by
  design and there was previously no way to audit or stop it.
- **Operator sign-in against one of the deployment's own Applications**, so
  buyers who already have an account do not keep a second password.
- **A sign-in screen that explains itself**: names the Application, carries its
  branding, echoes a failed email back, and links password reset.
- `PANEL_PRIMARY_SIGNIN=magic_link` for deployments whose operators never set a
  password.
- An Application's pooled mail now sends as `<Application> (via <Deployment>)`
  rather than the deployment's name alone.

## 2.0.0-rc.4

### Record a sale that no payment provider saw

`POST /api/v1/admin/applications/:id/subscriptions`

Until now the only way a subscription could become `ACTIVE` was a webhook from
Stripe, PayPal or Razorpay. If you sell by invoice, take a bank transfer, comp
an account, or are migrating off another billing system, there was no supported
way to record that at all — the closest thing to a documented procedure was
writing SQL against your production database.

A row written that way is inert. It skips the entitlement provisioner, so the
credits and licences the plan promises are never issued, and it emits nothing,
so every webhook consumer you have built on `subscription.activated` hears
nothing. Granting through this endpoint takes the same path a real activation
takes, so both happen:

```bash
curl -X POST https://api.example.com/api/v1/admin/applications/$APP_ID/subscriptions \
  -H "Authorization: Bearer $SUPER_ADMIN_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"planSlug":"pro","email":"buyer@example.com","note":"invoice INV-2026-0031"}'
```

- **Idempotent.** A subscriber already `ACTIVE` or `PAST_DUE` on the plan comes
  back with `activated: false` and a `200` instead of a `201`; nothing is
  written, re-provisioned or re-announced. It will not extend a live period — to
  move a grant to a new term, cancel it and grant again.
- **The period is real.** Defaults to one plan interval from now, on calendar
  anniversaries; pass `currentPeriodEnd` for whatever term you agreed. A one-off
  purchase (credit pack, perpetual licence) gets none, because it has none.
- **Cancel already works on it.** A granted subscription carries no provider,
  which is exactly what lets the existing cancel path schedule it to end at
  period end and the local expiry terminate it when the date arrives.
- **Audited.** Every grant writes `app.subscription_granted` to the security
  event trail — who, for whom, on what plan, until when, and why — visible to
  the workspace that owns the Application.

Deliberately behind `SUPER_ADMIN_KEY` rather than a workspace role: it is the
only billing write that creates entitlement with nothing behind it but an
assertion, and on a deployment where the operator is also the buyer that is a
lever aimed at your own limits. See `decisions.md`, 2026-08-03.

**Rekey Cloud**: this is the missing half of "your workspace exists as soon as
you have paid" below. Cloud sells with checkout off, so no activation was ever
emitted and the provisioning that shipped for it fired for nobody. It does now.

### Cancelling a PayPal subscription now does what the screen says

If you pay through PayPal, cancelling was not doing what you were told it would.

**You keep the time you paid for.** Asking to cancel at the end of the period
said "you keep everything you paid for until <date>" and then took it away
within seconds — mid-period, with no refund. PayPal has only one kind of
cancellation and it takes effect immediately, so that promise had nothing behind
it. Your access now runs to the date you were shown, and the last charge is the
last one: nothing is billed after you cancel.

**The first month counts too.** For a brand-new PayPal subscription there was no
renewal date on file, so every cancellation in the first period was treated as
immediate whatever you chose — the most common case, and the one the promise was
least true for. The renewal date is now recorded when the subscription starts.

**Subscribing again gives you a working Cancel button.** If you cancelled and
later resubscribed, the new subscription inherited the old cancellation: your
account page showed "Cancelling — ends" with a date that had already passed, the
Cancel button was replaced by Resubscribe, and cancelling through the API
reported success without doing anything — while the payments carried on. A
resubscribe now starts clean, and you can always cancel again.

**A failed cancellation is reported as one.** If PayPal refuses a cancellation,
you now get an error and can try again. Previously it was recorded on our side
as cancelled while PayPal kept billing, so nothing on the screen or in the
account page showed that anything was still running.

The customer-facing portal also stops promising a period end it cannot deliver:
when a cancellation really will take effect immediately, it now says so before
you confirm, instead of after.

### rekey.dev can confirm your email address

There is now a page at `rekey.dev/verify` that redeems the link in your
confirmation email. Until now the email was sent, the link 404'd, and an account
that needed confirming could not be confirmed — so a sign-up that asked you to
check your inbox was a dead end.

It answers every state honestly, because most of them are recoverable and the
old behaviour was to say nothing:

- **Confirmed** — you are done, and it names the address it confirmed.
- **Already confirmed** — the link was used once already, which is *fine*: the
  address is confirmed. This is a success message, not an error. Corporate mail
  scanners follow links before you do, and you should not be told your account
  is broken because your employer's security software clicked first.
- **Expired** — links last 24 hours; ask for another from the same page.
- **This link is for an older address** — you changed your email after the link
  was sent.
- **Not valid** — mangled link, or the account is gone.
- **No code** — you reached the page without a link.

Every one of those except "we could not reach the server" offers a way forward
on the page itself, so nothing sends you to support to make progress.

### Sign-up and sign-in stopped lying about unconfirmed accounts

Signing up for Rekey Cloud with confirmation required used to answer
**"Could not create your account. Please try again."** The account *had* been
created. Pressing the button again then said the email was already taken, which
reads as "somebody else has your address". It now says your account exists and
points you at your inbox.

Signing in with a correct password on an unconfirmed address used to give a
generic failure, which sent people round the reset-password loop fixing a
password that was never wrong. It now tells you to confirm your address, with a
link to ask for another confirmation email.

### "Forgot password?" exists

rekey.dev's sign-in page had no password-reset link at all. It has one now, and
`rekey.dev/reset` — where the reset email points — is a real page.

### Sign in to Rekey Cloud with Google or Discord

rekey.dev now offers social sign-in. Both providers assert a confirmed email
address, so signing in this way normally skips confirmation entirely.

Rekey has supported these providers for your own applications for some time;
this is Rekey Cloud finally using them for its own. Operators wanting the same
on their own sites configure it per Application under
Panel → Application → OAuth — unchanged.

### Sign operators in with an account they already have

An Application that is an OpenID Provider can now be the login for the operator
panel itself. Set two variables and the panel accepts an ID Token your own
deployment minted:

```bash
OPERATOR_OIDC_ISSUER=https://api.example.com/api/v1/mcp/account
OPERATOR_OIDC_CLIENT_ID=<client_id from POST /oauth/register>
```

Someone signing in this way is matched to an existing operator **by verified
email**, so anyone who already has an operator account keeps it — same
workspaces, same MFA, same passkeys. Nothing is duplicated and nothing is
orphaned. A first-time sign-in creates an operator subject to
`OPERATOR_SIGNUP_MODE` exactly like any other, so this is not a way around an
invite-only deployment.

Off unless you set both variables — `POST /api/v1/tenant/auth/oidc/assert`
answers `404` otherwise. Limited to issuers your own deployment hosts; a
third-party IdP is not supported yet. See `docs/operator-oidc-assertion.md`.

### Your own server can finish a sign-in it already did

`POST /api/v1/mcp/:slug/oauth/authorize/grant`

The interactive `/oauth/authorize` asks the user for a password, because there
is no SSO session to reuse. That is correct for someone else's app and wrong for
**your** server, which already holds a live session for the user it is asking
about — it was re-prompting for a password it had just accepted.

Present your Application secret key together with the user's live access token
and you get an authorization code for one of your own registered clients,
redeemable at `/oauth/token` like any other:

```
POST /api/v1/mcp/account/oauth/authorize/grant
Authorization: Bearer rp_live_...
X-Rekey-User-Token: <the user's live access token>

{ "client_id": "...", "redirect_uri": "...",
  "code_challenge": "...", "code_challenge_method": "S256",
  "scope": "openid email" }
-> { "code": "...", "expires_in": 60 }
```

This grants nothing a secret key could not already do — and because it demands
a live user token, it cannot be used on someone who has not signed in. A
publishable key is refused, an impersonated session is refused, the code is
single-use and PKCE-bound like any other, and every use is recorded in your
security events as `user.session_handoff_granted` naming the user and the
client.

### BREAKING · Every list endpoint now returns `{items, page}` instead of a bare array

A functional audit called `GET /api/v1/tenant/applications/:id/end-users` with
no `limit`. There were 36 end-users in the database. It answered `200` with 25
rows, and **nothing in the response said the other 11 existed**:

```
GET /api/v1/tenant/applications/{id}/end-users     (no limit passed)
-> 200 [ ...25 rows... ]              actual rows in the database: 36
```

A client that does not pass `limit` could not tell a complete list from a
truncated one. Twenty-six list endpoints had that shape. A schema audit then
validated 86 operations against the published document and found the same thing
from the other side: the document declared `{items, page}` for those operations
and the handlers returned an array.

**Before**

```json
{
  "success": true,
  "data": [
    { "id": "eu_1", "email": "a@example.com" },
    { "id": "eu_2", "email": "b@example.com" }
  ]
}
```

**After**

```json
{
  "success": true,
  "data": {
    "items": [
      { "id": "eu_1", "email": "a@example.com" },
      { "id": "eu_2", "email": "b@example.com" }
    ],
    "page": { "total": 36, "limit": 25, "offset": 0, "hasMore": true }
  }
}
```

`page.total` is the count of rows matching the query, ignoring `limit`/`offset`.
`page.hasMore` is `offset + limit < total`. Both come from `pageMeta()` in
`apps/api/src/lib/pagination.ts`, which every one of these endpoints now uses.

**46 operations changed** — the 44 the document already declared `okPage` for,
plus the two request-log endpoints (below):

- **Public / end-user:** `GET /auth/passkeys`, `GET /auth/sessions`,
  `GET /users/me/organizations`, `GET /users/me/organizations/{id}/members`,
  `GET /billing/plans`, `GET /billing/payments`, `GET /credits/ledger`.
- **Operator (tenant):** `GET /tenant/applications` and its
  `/{id}/plans`, `/{id}/coupons`, `/{id}/payments`, `/{id}/dunning`,
  `/{id}/end-users`, `/{id}/licenses`, `/{id}/usage-meters`,
  `/{id}/organizations`, `/{id}/email-logs`, `/{id}/webhooks`,
  `/{id}/webhooks/{endpointId}/deliveries`,
  `/{id}/billing-credentials/webhook-events`;
  `GET /tenant/auth/sessions`, `GET /tenant/auth/api-tokens`,
  `GET /tenant/workspace/members`, `.../members/{id}/grants`,
  `.../invitations`, `.../email-logs`, `GET /tenant/security-events`,
  `GET /tenant/operator/applications`,
  `GET /tenant/operator/applications/{id}/api-keys`.
- **Super-admin:** `GET /admin/tenants`, `GET /admin/applications`,
  `GET /admin/applications/{id}/plans`, `.../coupons`,
  `GET /admin/operator-invites`, and the ten `GET /admin/metrics/*` list
  endpoints.

Three of those were wrong in their own particular way and are also fixed:

- `GET /tenant/security-events` returned `{ "events": [...] }` — a key that
  appeared in no schema anywhere.
- `GET /admin/operator-invites` and all ten `GET /admin/metrics/*` endpoints
  returned pagination **flattened** next to the rows
  (`{items, total, limit, offset}`), one level up from where the document
  declared it, and with no `hasMore` at all.

Two more moved for the same reason even though the document already described
them accurately: `GET /tenant/auth/requests` and
`GET /tenant/applications/{id}/requests` wrapped their rows as
`{ "requests": [...] }` with no total. Both are backed by `api_request_logs`,
which grows with every request the deployment serves, and the panel had to keep
an over-fetch probe alive purely for them. Their `page.total` counts what the
pruner has left rather than every request ever made — these are capped
convenience tails, and the endpoint descriptions say so — but it is still the
real answer to "is there another page".

**Nine list endpoints are deliberately unchanged** — they remain bare arrays
because they are bounded by construction and cannot truncate: a user's linked
OAuth identities, an Application's API keys (hard-capped at 25 on the write
path, on both the tenant and admin routes), a plan's entitlement bundle, the
three configured billing-credential slots, per-Application end-user roles, the
fixed email-template registry, and the two `.slice(0, 20)` top-N metrics
(`webhook-endpoint-health`, `payments-by-app`). They are enumerated with their
reasons in `ALLOWED_BARE_ARRAYS` in `apps/api/test/openapi-contract.test.ts`.

**Migrating.** Read `.items` where you read the array, and `page.hasMore` where
you inferred "is there a next page" from `rows.length === pageSize`:

```ts
// before
const plans = await rekey.billing.getPlans();
plans.map(render);

// after
const { items, page } = await rekey.billing.getPlans();
items.map(render);
if (page.hasMore) { /* fetch offset: page.offset + page.limit */ }
```

Every consumer in this repo moved with it: the operator panel (its
`splitPage()` over-fetch shim is gone — it existed only because there was no
`total` to read), the super-admin console, the hosted portal, the marketing
account page, `@rekey.dev/node`, `@rekey.dev/react`, `@rekey.dev/cli`
(`apps list` and `plans list` gained `--limit` / `--offset` and now report
"showing N of M"), and `@rekey.dev/mcp` (every list tool gained `limit`/`offset`
and its description tells the model to check `page.hasMore`).

Several endpoints that had no bound at all are now bounded as a side effect —
`GET /tenant/operator/applications`, `GET /tenant/workspace/members`,
`.../invitations`, `GET /tenant/auth/sessions`, `GET /auth/sessions`,
`GET /auth/passkeys` and `GET /tenant/auth/api-tokens` all ran an unbounded
`findMany`. They take `limit`/`offset` now, defaulting to 50.

`PageMeta`, `Paged<T>` and `ListPage` are exported from
`@rekey.dev/shared-types` (and re-exported from `@rekey.dev/node` and
`@rekey.dev/react`) so the API, the SDKs and the consoles name one shape.

### BREAKING · Application responses no longer carry the encrypted-credential columns

`GET /api/v1/tenant/applications/:id` (and the list, create and super-admin
equivalents) returned the Prisma row verbatim, which includes
`billingCredentialsCiphertext`, `oauthCredentialsCiphertext` and
`emailCredentialsCiphertext` — the AES-256-GCM ciphertexts of the operator's
payment-provider keys, their per-provider OAuth client secrets, and their SMTP
or Resend credentials. Measured, not inferred: the Application detail response
carried 15 fields the `Application` schema does not declare, and three of them
were those.

They are useless to a client (only the API holds the key) and they are the one
thing on that row that must never leave the process, so they are stripped at
the response boundary. The dedicated read surfaces
(`GET .../billing-credentials`, `GET .../email/config`) already reported
presence as a boolean and never the material; nothing in this repo read the
ciphertexts off a response.

### Rekey Cloud: your workspace exists as soon as you have paid

Paying is now what creates your workspace. Previously the workspace was made
when you pressed **Open your workspace** on the account page — so between
paying and finding that button you owned nothing, and if you closed the tab
there was nothing waiting for you when you came back.

Now the subscription going active creates your operator account, your workspace
and your ownership of it, and writes the ceiling your plan pays for. When you
next sign in to rekey.dev, it is already there. The button still works and still
does the right thing if you press it — pressing it twice, or pressing it after
this has already run, will never give you a second workspace.

**One condition: your email address has to be verified.** Your workspace is tied
to your rekey.dev account by your verified email — that is what lets you sign in
to panel.rekey.dev in one click, with no key to copy. If your address is not
verified yet, we will not create an account under it, because doing so would
mean handing your workspace to whoever verifies that address afterwards. Verify
your email and the workspace is created within the minute; nothing is lost in
the meantime and your plan's ceiling is applied the moment it appears.

### Fixed (Rekey Cloud): a workspace key you had not redeemed did not count against your plan

Your plan covers a set number of workspaces. Until now the account page only
counted the workspaces that had actually been created — so a key you had been
issued and not yet redeemed counted for nothing, and you could keep pressing
**Generate a new key** and collect as many live keys as you liked. Every one of
them still turns into a real workspace when somebody signs up with it, which
meant a one-workspace plan could quietly become any number of workspaces.

Keys you are holding now count towards your allowance, exactly like the
workspaces you have already made. Concretely, if your plan covers one workspace:

- You have no workspace and no key → **Generate a new key** issues one.
- You are holding that key and have not redeemed it → the account page says so,
  and points you at panel.rekey.dev/sign-up to redeem it.
- You lost the key (it is only ever shown once) → **Replace key** revokes the
  old one and issues you a fresh one. Anyone still holding the old key can no
  longer use it. Your allowance does not change, and you are never left with two
  working keys at the same time.
- You have redeemed it and the workspace exists → the page tells you the
  allowance is used up and how to ask for more, as it did before.

Nothing you are holding today stopped working when this shipped. Keys already
outstanding stay valid and redeemable; they simply start counting from now on,
so the next key you ask for is the first one this rule applies to.

### Fixed (Rekey Cloud): cancelling made your subscription disappear from the account page

Cancelling takes effect at the end of the period you have already paid for — you
keep everything until then. The account page did not say so. The moment you
cancelled, the subscription card was replaced by "You are on the free plan",
dropping your plan name, the status, and the date your access actually runs to,
with no way back except emailing us.

The account page now shows what is really true:

- **Cancelling.** Your plan, `Cancelling`, and **Ends** with the date. Plus a
  sentence saying you keep everything you paid for until then and that nothing
  is deleted afterwards — your applications keep serving, you just cannot add
  more — and a **Resubscribe** button if you change your mind.
- **Ended.** The date it ended, what the free plan gives you, and the same
  **Resubscribe** button.

The confirmation step is also more careful. Cancelling normally schedules the
end of your subscription for the date you are paid up to, but that is only
possible for a subscription your payment provider is managing. For anything else
— including a subscription we set up for you by hand — cancelling ends it
immediately with no refund for the rest of the period. The confirmation now says
which of the two you are about to get, in those words, before you confirm.

### Fixed (Rekey Cloud): the cancel confirmation warned you about a loss that no longer happens

Hours after the paragraph above was written, the API stopped requiring a payment
provider to schedule a cancellation — precisely because subscriptions we set up
by hand were being ended on the spot when their owner asked for the end of the
period. Every Rekey Cloud subscription is one of those. The confirmation dialog
was not updated, so it went on telling every subscriber on the site that
cancelling would cost them the remainder of a period they would in fact have
kept. The same defect as the original, pointed the other way: copy frightening
people out of a cancellation that was perfectly safe.

The dialog now says what actually happens:

- **An active subscription with a renewal date** — the ordinary case, whether or
  not a payment provider is involved — keeps everything you paid for until that
  date. Confirming reads **Yes, cancel at period end**.
- **A subscription whose last payment has not gone through**, or one with no
  renewal date recorded, still ends straight away, and each now says which of
  the two it is instead of "this cannot be scheduled". If you think you have
  paid for time you have not used, get in touch and we will sort it out.

The rule behind this is no longer written down twice. It lives in
`@rekey.dev/shared-types` as `cancelsAtPeriodEnd`, exported from
`@rekey.dev/node`, and the API cancels from that same function — so what a
confirmation dialog promises and what the server does cannot drift apart again.
If you have built your own cancel confirmation, call it rather than
re-implementing the rule:

```ts
import { cancelsAtPeriodEnd } from '@rekey.dev/node';

const sub = await rekey.billing.getSubscription(userAccessToken);
const message = sub && cancelsAtPeriodEnd(sub)
  ? `You keep access until ${sub.currentPeriodEnd}.`
  : 'Cancelling takes effect straight away.';
```

### Added: `GET /billing/subscription?includeEnded=true` — a cancelled subscription stops vanishing

`GET /api/v1/billing/subscription` answers with the ACTIVE, PAST_DUE or PENDING
subscription and `null` otherwise. `null` is also the answer for somebody who
has never subscribed, so once a subscription reached CANCELED there was no way
for a billing page to tell a former customer apart from a stranger. It said the
only thing it could: "you are on the free plan". Cancel, reload the next day,
and your plan, your status and your end date were gone.

Pass `?includeEnded=true` and the endpoint falls back to your most recent
CANCELED or EXPIRED subscription **when, and only when, the answer would
otherwise have been null**. It cannot replace a live subscription: someone who
cancelled and resubscribed still gets the one they are paying for, and an
unfinished checkout still comes back as PENDING. So it is safe to add to a call
you already make — though leave it off where you are deciding access, because
there the strict question is the one you want.

```ts
// Billing page — "your Standard subscription ended on 1 July", not a blank slate
const sub = await rekey.billing.getSubscription(token, { includeEnded: true });

// Entitlement check — unchanged, and should stay that way
const live = await rekey.billing.getSubscription(token);
```

Default behaviour is unchanged, so existing SDK, portal and integrator calls
carry on exactly as before. `getSubscription` in `@rekey.dev/node` also gained
the `organizationId` option it had been missing, which `@rekey.dev/react`
already had.

Rekey Cloud's own account page now uses it, so a buyer who cancelled last month
comes back to the plan they were on and the date it ended, with a Resubscribe
button — rather than the page it showed somebody who had never bought anything.

### Three response schemas that described something the endpoint never returns

- **`GET /users/me` and `GET /auth/me` were structurally unsatisfiable.** Both
  declared `allOf: [EndUser, {required: [activeOrganizationId]}]`, and the
  `EndUser` component carried `additionalProperties: false` (generated from a
  `.strict()` zod schema). No JSON object could ever validate: with the field
  it is "additional", without it it is "required". The generator now strips
  `additionalProperties: false` from every component, which is what
  `lib/openapi.ts` has always said it does — its own header states these
  schemas describe "a floor, not a ceiling", and its `fromZod` comment claimed
  to delete the flag while deleting only `$schema`. 40 of the 55 components
  were shipping closed. Both `me` endpoints additionally now declare the
  `role`, `updatedAt`, `erasedAt` and `erasedBy` fields they return.
- **`GET /tenant/applications/{id}/end-users/{euid}/export`** declared
  `{"type": "string"}` — because it is documented as a file download — and
  returns a JSON object. A generated client typed it `Promise<string>`. It now
  references a new `EndUserExport` component written field-for-field against
  `EndUserExportDocument` in `@rekey.dev/shared-types`.
- **Closed-schema violations** are gone with the change above: a response may
  now legitimately carry more than its schema names, which is what these
  handlers have always done.

`apps/api/test/openapi-contract.test.ts` gained two assertions that keep all of
this from sliding back: no component may declare `additionalProperties: false`,
and every entry in `ALLOWED_BARE_ARRAYS` must still name a live bare-array
operation (so the list cannot keep dead entries that later read as permission).

### The published OpenAPI document now describes responses

`/docs/json` — and the copy shipped at `apps/marketing/public/openapi.json` —
described **one** response across the entire API. 275 of 276 operations carried
nothing but:

```json
"200": { "description": "Default Response" }
```

Request bodies and query parameters were complete and accurate; responses were
absent. So you could not generate a typed client, the `{success, data}` /
`{success, error}` envelope appeared nowhere, no error shape was described at
all, and every response field an integrator used had to be discovered by calling
the endpoint and reading what came back. Two independent external audits called
it half a contract.

Now: **275 of 278 operations declare a response schema**, over **55 named
components** in `components.schemas`. The envelope is defined once and
referenced, not inlined 278 times. Domain objects (`Application`, `Plan`,
`EndUser`, `Subscription`, `Payment`, `Coupon`, `License`, `Organization`,
`WebhookEndpoint`, `CreditLedgerEntry`, …) are **derived from the same
`@rekey.dev/shared-types` zod schemas the SDKs compile against**, so the document
cannot drift from the types.

Error responses are declared per operation from what the handler actually
throws, not as a blanket 400/401/500 — a route that can answer `402
CREDITS_INSUFFICIENT` or `409 PLAN_SLUG_TAKEN` now says so, with the `code`
string you will switch on.

Three operations declare no 2xx body because they have none: `GET
/api/v1/mcp/{slug}` and `GET /api/v1/tenant/mcp` answer `405` unconditionally
(the endpoints are POST-only JSON-RPC), and `GET
/api/v1/tenant/mcp/oauth/authorize` only ever redirects or renders HTML.

**These schemas are documentation, not serialisation.** Fastify normally
compiles `schema.response` with fast-json-stringify, which drops any field the
schema does not declare. A pass-through serializer is installed so that adding
these schemas cannot silently truncate a live response — runtime output is
byte-identical to before. The guard against drift is
`apps/api/test/openapi-contract.test.ts`, which fails if any operation loses its
response schema, if any declares no failure mode, or if a list endpoint declares
a bare array.

### Fixed: the published OpenAPI document was never valid OpenAPI 3.0

Four request-body properties used the draft-07 type-array form
(`type: ['string','null']`, `type: ['integer','null']`), which OpenAPI 3.0 —
the version this document declares — does not allow. Every real validator
rejected the file, so anyone who tried to generate a client got a parse error at
the door. That is plausibly why nobody reported the missing response schemas:
you could not get far enough to notice.

They are now `nullable: true`, which Fastify's ajv treats identically at runtime
(verified: `null` accepted, the typed value accepted, a wrong type still 400s).
The affected fields were `maxActiveEndUsers` and `maxProductionApps` on `PUT
/api/v1/admin/tenants/{id}/limits`, `appUrl` on `PATCH
/api/v1/tenant/applications/{id}/auth-config`, and `defaultPlanSlug` on `PATCH
/api/v1/tenant/applications/{id}/billing-config`. The document is now validated
by `@apidevtools/swagger-parser` in the test suite.

### Fixed: the OpenAPI document announced itself as `1.1.1`

The version was a hardcoded string in `apps/api/src/lib/swagger.ts` and had gone
three minor versions stale — the document about to become the frozen 2.0.0
public contract described itself as a 1.x document to every client generator,
registry, and integrator diffing it against the previous release.

It is now derived from `@rekey.dev/shared-types`'s `package.json` (the version
the packages, API, panel and portal share), and the test suite asserts that the
document version, the package version, and the top CHANGELOG heading all agree —
so a release cannot bump one and forget the others.

### Changed: session cookies decide `Secure` from the request, not from `NODE_ENV`

**Operator-visible effect:** the panel, admin portal, hosted portal, rekey.dev
account pages and `@rekey.dev/nextjs` now set `Secure` on every cookie they
write whenever the request did not arrive as plain HTTP on a loopback host —
regardless of what `NODE_ENV` is set to.

Every cookie in the stack previously decided `Secure` with
`process.env.NODE_ENV === 'production'`. That is a build-time answer to a
request-time question, and it failed in the direction that costs you the
session: a deployment behind TLS whose `NODE_ENV` was unset, or `staging`, or
anything Next did not inline as exactly `"production"`, handed out session
cookies with no `Secure` flag. A browser will replay those over plain HTTP.
Nothing about it was visible — the apps worked normally.

The decision now reads `X-Forwarded-Proto` (first hop), falling back to the
`Host`. Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`, `*.localhost`) are
treated as the developer machine they are and get no `Secure`; everything else
does. **If you terminate TLS somewhere the app cannot observe and your proxy
sets no `X-Forwarded-Proto`, set `REKEY_COOKIE_SECURE=false`** — otherwise the
browser will refuse the cookie and sign-in will not complete. That is the
intended failure: the insecure case is now something you opt into rather than
something you fall into.

### Changed: `docker-compose.yml` publishes on loopback by default

**Operator-visible effect:** after `docker compose --profile full up`, the API,
panel and portal are reachable at `localhost:3030` / `:3031` / `:3050` as
before, but no longer on the host's public interfaces. To expose them, set
`BIND_ADDRESS=0.0.0.0`.

Postgres and Redis were moved to loopback earlier for the same reason; the web
services were left publishing on `0.0.0.0` while `OPERATOR_SIGNUP_MODE`
defaulted to `open`. Together that meant bringing this stack up on a VPS to try
it out put an unauthenticated operator-signup endpoint on the internet, without
anyone choosing to. `docker-compose.prod.yml` — the file `DEPLOY.md` documents
for real deployments — is unaffected: it publishes no ports at all and routes
through Traefik.

`OPERATOR_SIGNUP_MODE` still defaults to `open`, because first boot has to be
able to create the first operator. The API now logs a `[SECURITY]` warning at
boot when it finds `open` under `NODE_ENV=production`, which covers deployments
that do not use this compose file. **If you set `BIND_ADDRESS=0.0.0.0`, set
`OPERATOR_SIGNUP_MODE=invite` in the same edit.**

### Added: `OPERATOR_MCP_DYNAMIC_REGISTRATION` closes anonymous MCP client registration

**Operator-visible effect:** none by default — the value is `open`, matching
today's behaviour, so Claude Desktop / Claude Code / Cursor keep connecting by
discovery alone. Set it to `disabled` once your clients are connected and
`POST /api/v1/tenant/mcp/oauth/register` answers `403
CLIENT_REGISTRATION_DISABLED`, with `registration_endpoint` dropped from the
RFC 8414 metadata. Clients that already hold a `client_id` are unaffected.

RFC 7591 registration on the operator authorization server was unconditionally
open, with no way to close it — unlike the per-Application MCP twin, which has
had `authConfig.dynamicClientRegistration` since it was written. Registering
grants no access on its own: an authorization code is only minted after an
operator signs into the panel and approves. What it does grant is an
*allowlisted* `redirect_uri` of the registrant's choosing, and `/oauth/authorize`
refuses any `redirect_uri` its client did not register. That allowlist entry is
the missing ingredient in a consent-phishing link.

The panel consent screen now also names the host the authorization code will be
delivered to, and says plainly that Rekey does not vouch for it.

### Fixed: operator MCP refresh-token reuse now revokes the whole family

**Operator-visible effect:** replaying an already-rotated operator MCP refresh
token revokes every live token for that operator, workspace and client, not just
the replayed one. Reconnect the MCP client to get a fresh grant.

Reuse detection refused only the presented token. On a leak that is backwards:
the attacker rotates first, so the replay is the *legitimate* client arriving
second — it got a single unexplained `invalid_grant` while the token the
attacker rotated into stayed valid for its full 30 days. The end-user refresh
path has burned the family on reuse for some time; this surface was left as a
seam. It now matches, including the same discrimination the end-user path makes:
a token that was rotated and then replayed burns the family, a token that was
deliberately revoked (sign-out) does not.

### Fixed: a well-formed request could make the API answer 500

An external black-box audit exercised all 276 operations and found two ways to
get a 500 out of a request that passed every auth check — one of them without
any credential at all.

**A NUL byte in any JSON string.** Postgres cannot store `\u0000` in a text
column, so it came back `22021 invalid byte sequence for encoding "UTF8"` and
19 routes turned that into a 500 — including `POST /api/v1/tenant/auth/sign-up`
and MCP dynamic client registration, both unauthenticated. A guard for exactly
this already existed for the query string; the body never got one. There is now
a `preValidation` hook that walks the whole parsed body — nested objects,
arrays, and object *keys* — and answers `400 INVALID_BODY`.

**An integer past what the column holds.** Every money and metering field was
written with a floor and no ceiling, so `Number.MAX_SAFE_INTEGER` passed
validation and Postgres answered `22003 value out of range for type integer`.
The audit reproduced it on usage quantity, plan amount and credit grants; the
same shape was present on 28 integer schemas in total, all now bounded — money
at 10^11 minor units, counts at `int4` range.

Neither was a privilege escalation: the audit confirmed cross-tenant isolation
and role gating held throughout (147 operations swept, zero leaks). They matter
because an unauthenticated caller could drive the error rate at will, and
because a 400 that names the field is the difference between a caller fixing
their own request and a caller filing a bug.

`GrantCreditsRequestSchema`, `TenantLimitsSchema` and `AuthConfigSchema` in
`@rekey.dev/shared-types` gained upper bounds to match. This narrows those
types: a value that was previously accepted and then failed at the database now
fails validation instead.

The published `openapi.json` was regenerated and picked up unrelated staleness
along with the new bounds — the MFA re-enrollment `code` requirement and the
organization-invitation email binding were both shipped without the document
being rebuilt.

### Behaviour change: a workspace MEMBER now starts with access to NO Application

**This changes what your existing members can see. Read the migration note.**

Per-application grants shipped with a back-compat rule: a MEMBER holding zero
grants kept the pre-grants behaviour — read-only access to **every** Application
in the workspace. That was written as an accommodation for members who predated
grants. But zero grants is also the state a freshly accepted MEMBER invitation
lands in, so the accommodation *was the live default*:

> Invite a contractor as MEMBER → they immediately read every Application's
> end-user roster (with email addresses), API-key metadata, billing-credential
> status, payments, webhooks, coupons, licences, organizations, email logs and
> per-app stats. An external audit confirmed 31 read endpoints on an
> Application nobody had granted them.

Grant-scoped access is now the default, not a mode the first grant opts you
into. A MEMBER with no grant on an Application gets `404 APPLICATION_NOT_FOUND`
— the same non-disclosure answer an ungranted Application already returned for
a member who held grants elsewhere. `GET /api/v1/tenant/applications` returns
`[]`. The operator MCP surface (`list_applications` and every tool built on it)
follows the same rule.

**Existing memberships are grandfathered, not broken.** The migration adds
`tenant_memberships.legacy_workspace_read` (default `false`) and backfills it
to `true` for every MEMBER row that already existed *and holds no grant*.
Those members keep exactly the access they have today. Only memberships created
from this release onward start closed. Revoking access your colleagues are
using right now, without an operator asking and on an upgrade whose timing they
do not control, would be an availability incident dressed as a security fix.

What changed for the grandfathered rows is that they are now **visible**:
`GET /api/v1/tenant/workspace/members` reports `legacyWorkspaceRead` per member,
so an owner can find everyone still on the blanket read and scope them. Setting
any grant on a membership clears the flag permanently.

One related fix: removing a member's **last** grant used to return them to
workspace-wide read — a de-scoping action that *widened* access. It now leaves
them with nothing, which is what the operator asked for.

**If you rely on the old behaviour**, grant explicitly:
`PUT /api/v1/tenant/workspace/members/:id/grants` with `APP_VIEWER`,
`APP_BILLING` or `APP_ADMIN` per Application.

### Contract change: config PATCH bodies reject unrecognised keys

`PATCH /api/v1/tenant/applications/:id/auth-config` answered **200** for a body
of entirely unrecognised keys and changed nothing:

```
PATCH …/auth-config
{"mfaa":"required","tokenAlgorithm":"none","sessionTtl":999999,"bogus":1}
→ 200, authConfig byte-identical afterwards
```

`mfaa` for `mfa` and `tokenAlgorithm` for `tokenAlg` — a one-character typo
silently no-opped the Application's **MFA policy** and **token signing
algorithm**, and the caller was told it succeeded. A patch body whose keys are
all optional has no shape left to fail on except the key names, so those are
now the check.

These bodies now answer `400 VALIDATION_ERROR` with an `issues` array naming
the offending key, matching `billing-config` (which already did this):

- `PATCH …/auth-config`
- `PATCH …/billing-config` (unchanged — the pattern the rest were aligned to)
- `PATCH …/portal`
- `PUT …/access` — the worst of them: `{"ipAllowlst": […]}` reported success
  while writing nothing, leaving an operator believing they had locked their
  secret keys to an office CIDR
- `POST …/end-user-roles`, `PATCH …/end-user-roles/:name`
- `PATCH …/usage-meters/:slug`

`PATCH …/usage-meters/:slug` was worse than the rest: it accepted the meter's
**own** fields, `{"name":"RENAMED","unit":"widget","active":true}`, applied only
`active`, and returned 200 echoing the *pre-edit* row — so the response itself
read as confirmation the rename had happened. `active` is the only editable
field and is now the only accepted one; its request body is also declared in
`/docs/json`, which previously published the operation with no `requestBody` at
all while `active` was in fact mandatory. Renaming a meter is deliberately not
implemented here: `slug` is what `Plan.meterSlug` binds against and `unit` is
the label every already-recorded usage row was measured in.

Injected `role` / `tenantId` / `applicationId` / `emailVerified` / `id` were
already correctly ignored on these bodies. The defect was that they were
ignored *silently*; callers are now told.

**If you send extra keys today**, they were already having no effect — you will
now get a 400 that names them instead of a 200 that hides them.

### Fixed: a payment-provider failure no longer surfaces as a 500 or a bogus 401

An exception from a provider SDK reached the global error handler unmapped, and
got whatever it could infer. A `StripeError` carries `.statusCode` and
`.message`, which was enough to be duck-typed as a framework 4xx:

```
POST /api/v1/billing/checkout        → 500 INTERNAL_ERROR ("contact support")
POST /api/v1/tenant/applications/{id}/plans
  → 401 {"code":"BAD_REQUEST","message":"Invalid API Key provided: sk_test_************2345",
         "fix":"Check the request shape against the route schema in /docs."}
```

Three signals disagreeing about one wrong stored credential — an upstream
status, a code meaning "your request was malformed", and a `fix` blaming the
caller's request shape — with a fragment of the operator's own key echoed back.
`POST …/billing-credentials/:provider/register-webhook` already did this right
(502, a stable code, an accurate `fix`); every provider call site now goes
through that same mapper.

All provider-SDK failures answer **502 `BILLING_PROVIDER_ERROR`** (documented in
`docs/errors.md`, so integrators can finally branch on "the provider, not the
caller, is at fault"). The mapper distinguishes who is reading:

- **Operator** routes (`/api/v1/tenant/*`) get the provider's own message,
  framed and length-bounded — they own the credential and it is the only thing
  that tells them which key is wrong.
- **End-user** routes (`/api/v1/billing/*`)