Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): enable better-auth user.changeEmail, and de-book delete-user in the auth route ledger (#7735) - #8017
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7735
POST /api/v1/auth/change-emailanswered 400CHANGE_EMAIL_DISABLEDandPOST /api/v1/auth/delete-useranswered 404, whileauth-route-ledger.tsbooked both as live SDK surface. better-auth shipsuser.changeEmail/user.deleteUseroff andplugin-authconfigured 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:
What changed
auth-manager.ts—user.changeEmail.enabledis set. The verification flow is better-auth's own, read offbetter-auth@1.7.0-rc.2'sapi/routes/update-user.mjsrather than off the docs:POST /change-emailmints a token carryingrequestType: 'change-email-verification'and hands it toemailVerification.sendVerificationEmail— the same callback andauth.verify_emailtemplate 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) andsendChangeEmailConfirmation(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.ts—delete-useris de-booked to a newdisableddisposition, carrying the ruling as its reason.disabledisgap's mirror image:gapmeans the server has the capability and the SDK does not express it;disabledmeans the SDK expresses it and the server refuses. It is notmismatch— 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.tsgoes red, becauseauth.deleteUserthen falls back to the dispatcher's* /auth/**row and re-enters the wildcard-only evidence class #3642/#3718 exist to keep at zero: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_SURFACEkeeps itsdelete-userentries, with the header now saying why: that list records publication, not liveness, and is checked for exact equality against the liveauth.apienumeration 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/callbackentry 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 adminremove-userpath 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.tsdrives realAuthManager.handleRequestcalls over a real better-auth pipeline against a real signed-up session:{ status: true }, mails the new address, and leaves the identity untouched until the link is followed;CHANGE_EMAIL_DISABLED;/delete-user/callbackhalf 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 loneexpect(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 carrycode: NOT_FOUND, and that is asserted.auth-route-ledger.conformance.test.tsgains 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 fromauth.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-userwith 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), andemail_verifiedis stored 0/1 because the ObjectQL adapter declaressupportsBooleans: false.Predicted-then-measured ablations
Each prediction was written before the mutation ran; all three matched.
changeEmail: { enabled: true }user.changeEmail.enabled is OFF … Booking it as sdk is the #7735 defectdelete-userassdkwithout wiring itdisabled-set pin); behaviour file greendelete-userrow outrightwildcardOnlyauth.deleteUser → POST /api/v1/auth/delete-user (via * /auth/**)Local verification
@objectstack/plugin-auth— 45 files / 1063 tests green;typecheckclean. Test-layer types measured the waycheck:type-check-debtdoes (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, andeslint --no-inline-configover 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:AuthRouteDispositionhas 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.tsandroute-ledger-response-schema.test.ts— are green.Generated by Claude Code