Skip to content

fix(plugin-auth): enable better-auth user.changeEmail, and de-book delete-user in the auth route ledger (#7735) - #8017

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-7735-changeemail-enable-deleteuser-debook
Aug 12, 2026
Merged

fix(plugin-auth): enable better-auth user.changeEmail, and de-book delete-user in the auth route ledger (#7735)#8017
huangyiirene merged 4 commits into
mainfrom
claude/issue-7735-changeemail-enable-deleteuser-debook

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#7735

POST /api/v1/auth/change-email answered 400 CHANGE_EMAIL_DISABLED and POST /api/v1/auth/delete-user answered 404, while auth-route-ledger.ts booked both as live SDK surface. better-auth ships user.changeEmail / user.deleteUser off and plugin-auth configured neither, so there was no switch to flip either way.

This PR resolves the two rows in opposite directions, per the maintainer ruling of 2026-08-12 — the ledger's job is to state what is mounted, so one row becomes true by wiring the capability and the other by withdrawing the claim.

Quoted verbatim, untranslated:

裁定:changeEmail 配置开启,delete-user 摘掉 mounted 记账。

What changed

auth-manager.tsuser.changeEmail.enabled is set. The verification flow is better-auth's own, read off better-auth@1.7.0-rc.2's api/routes/update-user.mjs rather than off the docs: POST /change-email mints a token carrying 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; GET /verify-email?token=… then applies it and re-issues the session cookie. Nothing is written until the link is followed.

Two options are deliberately left at their defaults, and the code says why: updateEmailWithoutVerification (would let a user whose current address is unverified swap emails with no confirmation at all — the one thing 「变更需确认」 rules out) and sendChangeEmailConfirmation (better-auth's opt-in extra step asking the OLD address to approve first — stronger against a hijacked session, but a policy decision plus its own template, so it stays a deliberate future design).

auth-route-ledger.tsdelete-user is de-booked to a new disabled disposition, carrying the ruling as its reason. disabled is gap's mirror image: gap means the server has the capability and the SDK does not express it; disabled means the SDK expresses it and the server refuses. It is not mismatch — that word is for a shape disagreement and its count is ratcheted to zero.

The disabled route is MARKED, not deleted, and that is measured. Deleting the row — the literal reading of "remove the two rows" — was tried: packages/client/src/client-url-conformance.test.ts goes red, because auth.deleteUser then falls back to the dispatcher's * /auth/** row and re-enters the wildcard-only evidence class #3642/#3718 exist to keep at zero:

methods matched only by a wildcard `**` family …
auth.deleteUser → POST /api/v1/auth/delete-user (via * /auth/**): expected 1 to be +0

Deleting the row honestly would mean deleting the SDK method too — a breaking public-API change the ruling did not ask for. client.auth.deleteUser() is unchanged and still refused at the endpoint, exactly as before.

BETTER_AUTH_MOUNTED_SURFACE keeps its delete-user entries, with the header now saying why: that list records publication, not liveness, and is checked for exact equality against the live auth.api enumeration in both directions. Removing a published entry would both redden the conformance test and misreport the mounted attack surface.

A correction to the issue body

The issue calls lines 247/249 "the disabled-route list". There is no such list: those lines are entries of BETTER_AUTH_MOUNTED_SURFACE, the machine-maintained inventory of what the catch-all publishes, and the same is true of the /delete-user/callback entry at line 196. They stay. The reconciliation the card asks for is therefore documentary — the header now states the publication-vs-liveness distinction and names this pair as its live instance — rather than a deletion.

The ruling's ordering premise has also expired without moving the conclusion: #7724 landed as e7a7506, so the admin remove-user path is no longer broken. The ruling gives two independent reasons for withholding self-service deletion, and the second — that it needs a deliberate B2B design — is the standing one.

Tests

New change-email-delete-user-wiring.test.ts drives real AuthManager.handleRequest calls over a real better-auth pipeline against a real signed-up session:

  • change-email answers 200 { status: true }, mails the new address, and leaves the identity untouched until the link is followed;
  • following the emailed link applies the change and marks the new address verified;
  • with no email transport it refuses for the honest reason ("Verification email isn't enabled"), not CHANGE_EMAIL_DISABLED;
  • delete-user is refused with 404 while the account survives, and its /delete-user/callback half likewise.

The delete-user refusal needed a discriminator built rather than asserted: better-auth's disabled branch is APIError.fromStatus('NOT_FOUND'), which carries no body at all, so a lone expect(404) could equally be a route that does not exist. The test pairs it with an anonymous call that answers 401 — the path is mounted and session-guarded, so 401-without-a-session beside 404-with-one can only be the capability switch. The callback half does carry code: NOT_FOUND, and that is asserted.

auth-route-ledger.conformance.test.ts gains the pin whose absence was the whole defect: each capability-gated row's disposition must agree with the switch the runtime reads. The two sides are independent — left from auth.options, the object better-auth's handlers consult, right from the ledger — so neither can be satisfied by restating the other.

Two findings the tests surfaced, both pinned rather than papered over: the platform's break-glass guard refuses /delete-user with 409 before better-auth's disabled check when the caller is the last local-credential holder (the test signs up two accounts so it measures the switch, not the guard), and email_verified is stored 0/1 because the ObjectQL adapter declares supportsBooleans: false.

Predicted-then-measured ablations

Each prediction was written before the mutation ran; all three matched.

AblationPredictedMeasured
remove changeEmail: { enabled: true }3 behavioural + 1 parity failureexactly those 4; parity said user.changeEmail.enabled is OFF … Booking it as sdk is the #7735 defect
re-book delete-user as sdk without wiring it2 conformance failures, behaviour untouchedexactly 2 (parity + the disabled-set pin); behaviour file green
delete the delete-user row outrightclient-url-conformance red on wildcardOnlyred, naming auth.deleteUser → POST /api/v1/auth/delete-user (via * /auth/**)

Local verification

@objectstack/plugin-auth — 45 files / 1063 tests green; typecheck clean. Test-layer types measured the way check:type-check-debt does (sibling tsconfig with the test exclusion dropped, dependency closure built): 108 errors, 0 from the files in this diff, against a recorded ceiling of 131 — three that the first draft introduced were fixed by typing the memory engine's options bag against the dispatch contracts rather than erasing it.

Gates: check:nul-bytes, check:engine-double-contract (the new fake engine reports pinned for both write verbs, not baselined), check:query-options-erasure (test surface unchanged at its ceiling), check:test-source-alias, check:docs-audit-scope, the three changeset gates, and eslint --no-inline-config over the changed files — all green.

Consumer sweep, stated with its direction and its limit: the prefix (downstream) filter --filter '...@objectstack/plugin-auth' could not be typechecked whole in a fresh worktree — consumers pull optional siblings outside that filter (service-cluster, service-datasource, service-storage, service-realtime), which report TS2307 as absent artefacts unrelated to this diff. The real consumption radius was swept instead: AuthRouteDisposition has no importer outside the ledger file, so widening the union cannot break an exhaustive switch anywhere, and the two cross-package guards that compile the ledger as a relative source file — client-url-conformance.test.ts and route-ledger-response-schema.test.ts — are green.


Generated by Claude Code

@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 11:40am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx(via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx(via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-auth)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx(via @objectstack/plugin-auth)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 13:40
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit a0a206fAug 12, 2026
26 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7735-changeemail-enable-deleteuser-debook branch August 12, 2026 14:03
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@huangyiirene@claude