CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# 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/*`)