fix(plugin-auth): the magic-link mail reads the recipient's own sys_user.locale (#15106) - #16062
fix(plugin-auth): the magic-link mail reads the recipient's own sys_user.locale (#15106)#16062os-warren wants to merge 1 commit into
sys_user.locale (#15106)#16062Conversation
…ser.locale (#15106) `sendMagicLink` was the last of the five auth mail sends still on the two-rung #14319 ladder — the request's `Accept-Language`, then the deployment default. #14762 put the recipient's stored `sys_user.locale` above both at the three sends that hold a user row and #14641 reached the invitation; the magic link was fenced out because it is handed `{ email, url, token }` and no row, so the column has to be read on the address rather than on an id. It now reads the column behind the existing placeholder-address refusal, in the shape #14641 gave the invitation send: one projected `findOne` on `sys_user` under a system context, best-effort, never a reason a send fails. This completes the #14788 option-D ladder across the whole auth mail surface. The request rung is kept rather than replaced — a magic link is requested BY its recipient, so its `Accept-Language` is the recipient's own and remains the legitimate second rung. The address is lowercased for the lookup: better-auth applies no case transform to the magic-link request body, while `findUserByEmail` — what `/magic-link/verify` resolves the same link with — matches on `email.toLowerCase()`, so the column is read for the row the link will sign into. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c793bc24c53856d493a0fe82a2b6c580fad5d454 && git checkout c793bc24c53856d493a0fe82a2b6c580fad5d454
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1a7a7c954b9142394c0b51975e3605360712af76 291a7319060fdec20e924208aa8e24bc4ad1fd1b && git checkout -B drift-repro 1a7a7c954b9142394c0b51975e3605360712af76 && git merge --no-ff 291a7319060fdec20e924208aa8e24bc4ad1fd1b
node scripts/docs-audit/affected-docs.mjs --json 1a7a7c954b9142394c0b51975e3605360712af76
|
Fixes #15106
sendMagicLinkwas the last auth mail send still on the two-rung #14319 ladder. It now reads the recipient's ownsys_user.locale, completing the #14788 option-D ladder across the whole auth mail surface.Ruling D, read at its source
Read from #14788 comment 5522880441 (os-project-manager, 2026-09-03, maintainer verbatim 「同意」), not from the card's paraphrase. The card's paraphrase turns out to be faithful; two things the ruling says that the card does not are load-bearing here:
GET /auth/me/localization—sys_user.localewhen set → the request'sAccept-Language→ the deployment default — and the ruling derives its order from [Decision] May a user set their ownsys_user.locale? — the ADR-0092 D2 self-service whitelist stays{name, image}after #13881 (column landsreadonly, system-context writes only) #14787's ruling B: the column is a user-stated preference, not an administered value. The application of that same ladder to mail sends comes from plugin-auth: auth SMS (OTP / invite texts) and request-less auth mail keep the deployment locale —sys_user.localeexists now and is not read #14762, which the ruling explicitly unblocks in its Execution paragraph ("plugin-auth: auth SMS (OTP / invite texts) and request-less auth mail keep the deployment locale —sys_user.localeexists now and is not read #14762 (pm:blocked) re-reads itsBlocked-by:once this lands"). So this PR extends a ladder that a landed PR already applied at four sends — not a shape invented here.Accept-Language; it does not remove the header rung.Point 2 answers the open question the card raised and the triage told the lane to answer rather than skip:
That is true, and it argues for keeping the request rung — which this PR does. It does not argue for putting the header above the column, because #14787's ruling B makes the column a preference the user stated for themselves, which is stronger evidence of intent than the header the browser happened to send. The header remains the second rung and still answers for any account that has stated no language (pinned). No narrowing of the ruling, so nothing goes to the decision inbox.
The enumeration — the card's "the one", verified
The card and its triage disagree on the count ("the fifth auth send" in the body, "this is the fourth" in the triage comment). Measured at
origin/main(2e3576503), both readings resolve: five mail sends, of which magic link was the fourth to read the column and the last one that did not.Population (mechanism-anchored, not keyword-guessed):
IEmailServicedeclares exactly two delivering members,sendandsendTemplate(packages/spec/src/contracts/email-service.ts). Every mail plugin-auth sends goes throughsendTemplate, and there are exactly five such call sites, all inauth-manager.ts.sendResetPassword{ id: user.id }sendVerificationEmail{ id: user.id }sendInvitationEmail{ email }f074616e6)sendMagicLinksendChangeEmailNotice{ id: from.id }Sends 1, 2, 4 and 5 always address an existing account; 3 may not. So among auth mails to an existing user, the magic link was indeed the only one ignoring the column — the card's headline holds in both directions. Two SMS sends (
sendOTP,sendPasswordResetOTP, viadeliverPhoneOtp) read the column on{ phone_number }already and are not mail.Controls, both of which fire (a search-based claim with no firing control is not a reading):
grep -rln "sendTemplate("acrosspackages/hits 16 files outside plugin-auth (plugin-email,service-messaging,spec,platform-objects). The pattern works; the scoping to one file is a fact about plugin-auth, not a broken grep.grep -rn "\.send("acrosspackages/plugins/plugin-auth/src(non-test) returns three hits, all the SMS service (sms.send(...)at two call sites plus one docstring). There is noemail.send(bypassingsendTemplate.grep -rnE "^\s*send[A-Za-z]*:"over the same tree enumerates every wired better-auth callback — four email, three SMS — with no unaccounted mail callback.send-verification-email.tsre-dispatches throughsendVerificationEmailrather than sending itself.The fix
One
findOneonsys_user, behind the existing placeholder-address refusal and behind the no-transport early return, threaded intoemailLocaleArg's stored parameter — the shape the card suggested and the shape #14641 gave the invitation send.AuthManager.storedRecipientLocalealready takes an arbitrarywhere, so this is a call site, not a mechanism.Two branches, because a magic link is also a sign-up. Measured against the installed better-auth 1.7.2 rather than assumed:
/sign-in/magic-linksends without looking the address up, and/magic-link/verifycreates the user unlessdisableSignUp. An address with no row therefore falls through to the rungs below, exactly as invitation branch 2 does.The address is lowercased for the lookup, and the invitation site's raw pass-through is not copied. Also measured in 1.7.2:
signInMagicLinkBodySchemaapplies no case transform, so what reaches the callback is whatever the caller typed;internalAdapter.findUserByEmail— the lookup/magic-link/verifyresolves this very link with — matches onemail.toLowerCase(). Reading the column on that spelling keeps the row the mail is about identical to the row the link will sign into. The address the mail is sent to is untouched.A
storedRecipientLocalefailure still never blocks a send: a magic link is itself the credential.Pins, with their populations stated
packages/plugins/plugin-auth/src/auth-email-locale.test.ts, +17 cases (51 → 68 in the file; the package suite is 2157 tests over 102 files, all green).sendMagicLinkalone — population: this one send. Three-rung precedence with three distinct tags; the reverse with the tags swapped (so no pin can pass because one tag always wins); branch 2 with a positive control for the zero driven through one engine, which is what separates "the read ran and found nothing" from "the read never ran"; the predicate itself (address, projected, system context); the lowercase match; the junk-at-rest literals; a failing read; no data engine; no transport; the absent-key contract; the placeholder refusal still ordered before any read.sendTemplatesites, which is the whole of plugin-auth's mail surface for the reasons above, and the same fivedriveAllFive/TEMPLATESalready name. Each recipient gets a distinct tag so no send can borrow another's answer; the magic link's tag differs from the same person's id-keyed row, which is what proves it reads by address and not by id.A passing per-send pin would only have proved something about that send. The class pin is the one that carries "the whole surface answers per recipient".
Mutation proof
Fix committed first, then reverted on disk, pins run RED, restored under
trap … EXIT INT TERMwith an absolute path andgit checkout HEAD -- ABSOLUTE_PATH.Mutation confirmed on disk at the anchor, by counting the deleted and injected text (never a bare
--stat):email: recipientEmail.toLowerCase()1 → 0,emailLocaleArg(ctx, storedLocale)1 → 0,emailLocaleArg(ctx),→ 1; blob574c9270…→a145d8d9….RED:
Tests 6 failed | 62 passed (68). The class pin's assertion, verbatim — position 4 is the magic link falling back to the deployment default:and the per-send ladder pin:
AssertionError: expected 'zh-CN' to be 'ja-JP' // Object.is equality— the header winning where the column should.Restore proved by both halves:
git hash-objectequals theHEADblob574c92703bef747011327133814a7ed60a8d064e(a non-empty hash — an empty one would be a failure, not a nothing-to-compare), andgit diff HEADis empty with a cleangit status --porcelain.Verification, all at the shipped commit
Gate family derived by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the script's own change set. Its Reconciliation line reads 55 families (43 by path + 7 by change kind + 7 declared whole-tree, 2 reached both ways); harvested with--commandsso neither spelling nor section could be dropped. All 55 ran green at291a73190, re-run after the final commit so the ratchet readings are about the tree that ships.Two of them first answered exit 3 — PREREQUISITE NOT MET, which is not a red gate and not a pass:
check:dual-build-cjs-loads("this gate reads built output, and some package has no dist/") andcheck:type-check-debt("--re-measure cannot run: 5 workspace dependenc(ies) … have no built type entry point on disk"). Both were converted to real measurements by building the closure the gates name, and both are counted green above only from that second run:check-dual-build-cjs-loads—provenance — entries/packages/cjsFiles/probes: this run 103/66/619/1 · floors 90/58/520/1check-type-check-coverage --re-measure: OK — 12 ledger entr(ies) re-measured in 105.2s, 140 raw tsc error(s) total, none above its recorded number.pnpm --filter @objectstack/plugin-auth typecheckexit 0, withtscechoed in the output rather than inferred from a zero — the run also carriescheck:test-typecheck: OK — … 10 file(s) / 94 error(s) / 23 pinned signature(s) held, so the new test file is inside a checked program rather than merely transpiled. Its first invocation had also answered exit 2 on a missing module (examples/basic-usage.ts … Cannot find module '@objectstack/plugin-auth'), which is NOT MEASURED; the reading above is from after the package's own build.NOT MEASURED, stated plainly: the six workflow-valued families
dispatch-gatesprints outside its total ($RUNNER_TEMP/${{ matrix.shard }}in argv — the tool refuses to invent a local invocation, and so do I); repo-widepnpm lint, which CI owns; and any runtime behaviour of a real deployment — this change is pinned at the callback boundary with a fake engine and a fake transport, not dogfooded in a browser.Not in this PR
No adjacency to #15981 or #15972 — nothing here reads or writes a name-derived authority, and no write path is touched.
packages/plugins/plugin-auth/src/auth-manager.tsis shared with the #15948 change that landed as8e0b29758(already in this branch's base), but at a different member: that one moved the session payload'spositions[]; this one touches a mail callback. #14641 (invitations) stays out of scope — and has since landed on its own.Generated by Claude Code