Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/change-email-enabled-delete-user-debooked.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/plugin-auth": minor
---

feat(plugin-auth): `POST /auth/change-email` works — better-auth's `user.changeEmail` is configured, with verification (#7735)

`auth.changeEmail()` answered **400 `CHANGE_EMAIL_DISABLED`** on every
deployment. better-auth ships the capability off and `plugin-auth` never
configured it, so there was no product switch to flip — while
`auth-route-ledger.ts` booked the route as a live SDK surface. The route table
was right about the product and wrong about the runtime.

`user.changeEmail.enabled` is now set, and the change is **confirmed by email**
before it applies:

1. `POST /api/v1/auth/change-email { newEmail }` mints a verification token and
sends it to the **new** address, through the same
`emailVerification.sendVerificationEmail` callback (and `auth.verify_email`
template) that sign-up verification uses. Nothing is written yet — an
unconfirmed request leaves the identity untouched.
2. `GET /api/v1/auth/verify-email?token=…` applies it: the address changes,
`email_verified` becomes true, and the session cookie is re-issued on the new
identity.

Two better-auth options are deliberately left at their defaults, because each is
a policy in its own right: `updateEmailWithoutVerification` (would let a user
whose current address is unverified swap emails with no confirmation at all) and
`sendChangeEmailConfirmation` (better-auth's opt-in extra step asking the OLD
address to approve first).

**A deployment with no email transport** now answers 400 *"Verification email
isn't enabled"* instead of `CHANGE_EMAIL_DISABLED` — a fixable configuration
statement rather than "the platform does not offer this". Wire an email service
(`setEmailService`, or register the kernel `email` service) to enable the flow.

**Self-service account deletion stays off, and now says so.**
`POST /auth/delete-user` is published by better-auth's catch-all but
`user.deleteUser` is deliberately not configured, so it answers 404 (as does its
`GET /auth/delete-user/callback` half). Its route-ledger row is re-booked from
`sdk` to the new `disabled` disposition carrying that reason, so the ledger no
longer advertises a dead route. `client.auth.deleteUser()` is unchanged and
still reaches the endpoint — it is refused there, as it was before this release.
Self-service deletion in a B2B tenancy touches record ownership and tenant data,
and needs a deliberate design; nothing about its behaviour changes here.
53 changes: 53 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -922,6 +922,59 @@ export class AuthManager {
// the objectql `SysUser` object def (provisioned by boot schema-sync)
// and read by a GUARDED system query in resolveCtx (can only no-op,
// never break auth). better-auth stays oblivious to the extra column.

// ── #7735 — self-service email change, ON with verification ────────
// better-auth ships `changeEmail` OFF, so `POST /change-email` answered
// 400 CHANGE_EMAIL_DISABLED on every deployment while
// `auth-route-ledger.ts` booked the route as a live SDK surface. The
// ledger was right about the product and wrong about the runtime;
// maintainer ruling 2026-08-12 settled it by making the runtime true:
// 「`user.changeEmail` 在 plugin-auth 配置开启,带验证流程(变更需确认,
// 策略按 better-auth 常规)」.
//
// THE FLOW, as better-auth 1.7 actually implements it (read off
// `better-auth/dist/api/routes/update-user.mjs`, not off the docs):
// 1. `POST /change-email {newEmail}` mints a JWT carrying
// `{ email: current, updateTo: newEmail, requestType:
// 'change-email-verification' }` and hands it to
// `emailVerification.sendVerificationEmail` — the same callback
// (and `auth.verify_email` template) sign-up verification uses,
// addressed to the NEW address. Nothing is written yet.
// 2. `GET /verify-email?token=…` applies it: `updateUserByEmail(old,
// { email: newEmail, emailVerified: true })`, then re-issues the
// session cookie on the new identity.
// So the change is confirmed by proving control of the new mailbox —
// 「变更需确认」 — and an unclicked request changes nothing.
//
// TWO OPTIONS DELIBERATELY LEFT AT THEIR DEFAULTS, because each is a
// policy this ruling did not decide:
// • `updateEmailWithoutVerification` — would let a user whose CURRENT
// address is unverified swap emails with no confirmation at all.
// That is the one thing the ruling names; leaving it false keeps
// every path confirmed.
// • `sendChangeEmailConfirmation` — better-auth's opt-in EXTRA step
// that asks the OLD address to approve first (old → new, two
// clicks). Stronger against a hijacked session, and it needs a
// decision about which address is authoritative plus its own
// template; 「策略按 better-auth 常规」 is the single-step default,
// so the two-step variant stays a deliberate future design.
//
// No email transport wired ⇒ the `emailVerification` block below is
// absent ⇒ better-auth answers 400 "Verification email isn't enabled".
// That is the honest answer for a deployment with no mailbox, and a
// different sentence from "the platform does not offer this".
//
// The write itself already worked: `sys_user.email` is schema-`readonly`
// and the readonly-strip drops non-system updates, but the adapter runs
// better-auth's own writes as system (#3164, `withSystemContext` in
// objectql-adapter.ts), so the applied change persists.
//
// ⛔ `deleteUser` is NOT configured here, by the same ruling — see the
// `disabled` row for `POST /api/v1/auth/delete-user` in
// `auth-route-ledger.ts` for why, and for what has to be decided first.
changeEmail: {
enabled: true,
},
},
account: {
...AUTH_ACCOUNT_CONFIG,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,17 @@ const ENV_KEYS = [
const savedEnv: Record<string, string | undefined> = {};

let live: Set<string>;
/**
* The options object better-auth was actually constructed with — the same one
* its handlers read as `ctx.context.options`, so a capability switch read here
* is the switch the runtime enforces, not a restatement of the ledger.
*/
let liveOptions: {
user?: {
changeEmail?: { enabled?: boolean };
deleteUser?: { enabled?: boolean };
};
};

beforeAll(async () => {
for (const k of ENV_KEYS) { savedEnv[k] = process.env[k]; delete process.env[k]; }
Expand All@@ -71,7 +82,9 @@ beforeAll(async () => {
// handling would — so the table enumerates identically.
const auth = (await manager.getAuthInstance()) as unknown as {
api: Record<string, { path?: string; options?: { method?: string | string[] } }>;
options: typeof liveOptions;
};
liveOptions = auth.options;

live = new Set<string>();
for (const endpoint of Object.values(auth.api ?? {})) {
Expand DownExpand Up@@ -177,3 +190,73 @@ describe('auth route ledger hygiene', () => {
expect(AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'mismatch').length).toBeLessThanOrEqual(0);
});
});

// ───────────────────────────────────────────────────────────────────────────
/**
* #7735 — the ledger's disposition for a capability-gated route must agree with
* the SWITCH THE RUNTIME READS.
*
* This is the check whose absence was the whole defect. `change-email` and
* `delete-user` are published by the catch-all unconditionally, so every guard
* that asks "does better-auth serve this path" was green while one route
* answered 400 `CHANGE_EMAIL_DISABLED` and the other 404 — mounted, ledgered as
* live SDK surface, and dead. Path existence cannot see a feature switch.
*
* Both sides here are read independently: the left from `auth.options`, the
* object better-auth's own handlers consult (`ctx.context.options.user
* ?.changeEmail?.enabled`), the right from the ledger row. Deleting the config
* in auth-manager.ts turns this red without touching the ledger, and re-booking
* a `disabled` row as `sdk` turns it red without touching the config — a pin
* that derived both sides from the ledger could do neither.
*/
describe('#7735 — capability switches ↔ ledger disposition', () => {
/** The routes whose liveness is a better-auth `user.*` feature switch. */
const CAPABILITY_GATED = [
{
route: 'POST /api/v1/auth/change-email',
switchName: 'user.changeEmail.enabled',
isOn: () => liveOptions?.user?.changeEmail?.enabled === true,
},
{
route: 'POST /api/v1/auth/delete-user',
switchName: 'user.deleteUser.enabled',
isOn: () => liveOptions?.user?.deleteUser?.enabled === true,
},
] as const;

it.each(CAPABILITY_GATED)(
'the row for $route matches the live $switchName',
({ route, switchName, isOn }) => {
const row = AUTH_ROUTE_LEDGER.find((e) => e.route === route);
expect(row, `${route} is missing from AUTH_ROUTE_LEDGER`).toBeDefined();

const on = isOn();
expect(
row!.disposition,
on
? `${switchName} is ON, so ${route} really is a live SDK surface and the ledger must book it as \`sdk\`.`
: `${switchName} is OFF, so ${route} is refused at runtime. Booking it as \`sdk\` is the #7735 defect: `
+ 'either wire the capability, or leave the row `disabled` with the reason.',
).toBe(on ? 'sdk' : 'disabled');
},
);

it('every `disabled` row names its refused capability, and the set is the reviewed one', () => {
const disabled = AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'disabled');

// Pinned, not counted: a second withheld capability is a product decision,
// so it must arrive as a diff someone reads — the same reason
// BETTER_AUTH_MOUNTED_SURFACE is checked for equality rather than growth.
expect(
disabled.map((e) => e.route).sort(),
'the `disabled` set changed. A route may only sit here with a ruling behind it (#7735); '
+ 'wiring one up means moving it back to `sdk` in the same PR as the config.',
).toEqual(['POST /api/v1/auth/delete-user']);

for (const row of disabled) {
// `note` is what makes the state honest rather than merely quiet — the
// hygiene test above requires one; this requires it to say something.
expect(row.note ?? '', `${row.route} must say WHICH switch is off`).toMatch(/deleteUser|changeEmail/);
}
});
});
61 changes: 57 additions & 4 deletions packages/plugins/plugin-auth/src/auth-route-ledger.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,30 @@ export type AuthRouteDisposition =
/** Public, unauthenticated browser-facing route. */
| 'public'
/** Server and client disagree on the shape — needs reconciliation. */
| 'mismatch';
| 'mismatch'
/**
* PUBLISHED BY THE CATCH-ALL, REFUSED AT RUNTIME (#7735). The path resolves —
* better-auth registers the endpoint unconditionally, so it is in
* `BETTER_AUTH_MOUNTED_SURFACE` and it is NOT a 404-by-absence — but the
* capability behind it is deliberately not configured, so every call is
* refused. `note` MUST say which switch is off and what has to be decided
* before it goes on.
*
* This is `gap`'s mirror image, and the pair is why a separate word was worth
* adding rather than reusing one: `gap` means the server has the capability
* and the SDK does not express it; `disabled` means the SDK expresses it and
* the server refuses. Both are "not a live product surface", and neither is a
* `mismatch` (that word is for a shape disagreement, and its count is
* ratcheted to zero).
*
* ⛔ A `disabled` row is a LEDGER STATE, never a parking space. It exists so
* the ledger can say "we know, and here is the decision it is waiting on"
* instead of booking a dead route as `sdk`; the conformance suite pins the
* set of them against the live better-auth options, so a row cannot sit here
* while the capability is quietly switched on, nor be re-booked as `sdk`
* while it is off.
*/
| 'disabled';

export interface AuthRouteLedgerEntry {
/** `VERB /api/v1/auth/...` — full wire path at the default base. */
Expand All@@ -75,7 +98,12 @@ export interface AuthRouteLedgerEntry {
*/
source: 'better-auth' | 'objectstack';
disposition: AuthRouteDisposition;
/** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */
/**
* Dotted method path on `ObjectStackClient` — required when disposition is
* `sdk`, and deliberately KEPT on a `disabled` row (#7735): the SDK method
* still exists and still builds this URL, so erasing the name would hide
* exactly the fact the row is there to record.
*/
client?: string;
/** Optional better-auth plugin this route needs (absent = always mounted). */
requires?: string;
Expand DownExpand Up@@ -115,9 +143,18 @@ export interface AuthRouteLedgerEntry {
}

export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
{ route: 'POST /api/v1/auth/change-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changeEmail' },
{ route: 'POST /api/v1/auth/change-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changeEmail', note: 'live since #7735: auth-manager.ts sets user.changeEmail.enabled, and the confirmation link rides emailVerification.sendVerificationEmail to the NEW address' },
{ route: 'POST /api/v1/auth/change-password', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changePassword' },
{ route: 'POST /api/v1/auth/delete-user', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.deleteUser' },
// #7735 — self-service account deletion is NOT wired, and this row says so
// rather than booking it as a live SDK surface. Maintainer ruling
// 2026-08-12, verbatim: 「`delete-user` 从 ledger 摘掉 mounted 记账(记为
// disabled/未接线,诚实状态)。⛔ 不配置 `user.deleteUser`:B2B 多租户下自助删号
// 牵连记录归属与租户数据 …… 自助删号需要一次 deliberate 设计,不由一张 QA 卡带
// 出来」. The ruling's other reason — that the admin-side `/admin/remove-user`
// was itself broken — has since expired (#7724 landed), and the conclusion
// does not move with it: the design question is the standing one, and a
// future design starts from #7724's deletion semantics.
{ route: 'POST /api/v1/auth/delete-user', family: 'core-auth', source: 'better-auth', disposition: 'disabled', client: 'auth.deleteUser', note: 'better-auth publishes the endpoint but user.deleteUser is deliberately unconfigured, so it answers 404 (as does its GET /delete-user/callback half); self-service deletion needs a deliberate B2B design first — maintainer ruling 2026-08-12 on #7735' },
{ route: 'GET /api/v1/auth/get-session', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.me', note: 'auth.me and auth.refreshToken both target it' },
{ route: 'POST /api/v1/auth/link-social', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.linkSocial' },
{ route: 'GET /api/v1/auth/list-accounts', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.list' },
Expand DownExpand Up@@ -179,6 +216,22 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
* entry here is a new publicly-mounted auth endpoint, which is a security
* surface change even when it is an intended one.
*
* ⚠️ PUBLICATION, NOT LIVENESS (#7735). This list answers "what does the
* catch-all expose", and nothing else — better-auth registers several endpoints
* unconditionally and then refuses them at runtime when the feature switch
* behind them is off, so an entry here is NOT evidence that a call succeeds.
* Today that gap is `POST /api/v1/auth/delete-user` and
* `GET /api/v1/auth/delete-user/callback`: both are published, both answer 404,
* because `user.deleteUser` is deliberately unconfigured. The claim about
* whether a route WORKS lives one list up, in `AUTH_ROUTE_LEDGER`, where that
* pair carries the `disabled` disposition and its reason.
*
* ⛔ So do not "reconcile" a disabled route by deleting it from here. This list
* is checked for EXACT equality against the live `auth.api` enumeration in both
* directions; removing a published entry makes the conformance test red and
* would misreport the mounted attack surface, which is the one thing this list
* exists to keep honest.
*
* The two `/.well-known/*` entries are not under the base path: `auth-plugin.ts`
* mounts those discovery documents at the app root (RFC 8414 / OIDC require it).
*/
Expand Down
Loading
Loading