Skip to content

fix(plugin-auth): the magic-link mail reads the recipient's own sys_user.locale (#15106) - #16062

Draft
os-warren wants to merge 1 commit into
mainfrom
claude/issue-15106-magic-link-locale
Draft

fix(plugin-auth): the magic-link mail reads the recipient's own sys_user.locale (#15106)#16062
os-warren wants to merge 1 commit into
mainfrom
claude/issue-15106-magic-link-locale

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #15106

sendMagicLink was the last auth mail send still on the two-rung #14319 ladder. It now reads the recipient's own sys_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:

  1. The ladder is stated for GET /auth/me/localizationsys_user.locale when set → the request's Accept-Language → the deployment default — and the ruling derives its order from [Decision] May a user set their own sys_user.locale? — the ADR-0092 D2 self-service whitelist stays {name, image} after #13881 (column lands readonly, 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.locale exists 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.locale exists now and is not read #14762 (pm:blocked) re-reads its Blocked-by: once this lands"). So this PR extends a ladder that a landed PR already applied at four sends — not a shape invented here.
  2. Ruling D inserts the column above 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:

a magic link is requested BY the recipient, so the request rung is legitimate here in a way it is not for an admin-initiated reset.

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): IEmailService declares exactly two delivering members, send and sendTemplate (packages/spec/src/contracts/email-service.ts). Every mail plugin-auth sends goes through sendTemplate, and there are exactly five such call sites, all in auth-manager.ts.

# send recipient predicate read the column before this PR?
1 sendResetPassword { id: user.id } yes (#14762)
2 sendVerificationEmail { id: user.id } yes (#14762)
3 sendInvitationEmail { email } yes (#14641, landed as f074616e6)
4 sendMagicLink none no
5 sendChangeEmailNotice { id: from.id } yes (#14762)

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, via deliverPhoneOtp) 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):

  • Control 1 — the pattern is not silently over-narrow: grep -rln "sendTemplate(" across packages/ 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.
  • Control 2 — no second mechanism: grep -rn "\.send(" across packages/plugins/plugin-auth/src (non-test) returns three hits, all the SMS service (sms.send(...) at two call sites plus one docstring). There is no email.send( bypassing sendTemplate.
  • Closing the callback surface: 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.ts re-dispatches through sendVerificationEmail rather than sending itself.

The fix

One findOne on sys_user, behind the existing placeholder-address refusal and behind the no-transport early return, threaded into emailLocaleArg's stored parameter — the shape the card suggested and the shape #14641 gave the invitation send. AuthManager.storedRecipientLocale already takes an arbitrary where, 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-link sends without looking the address up, and /magic-link/verify creates the user unless disableSignUp. 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: signInMagicLinkBodySchema applies no case transform, so what reaches the callback is whatever the caller typed; internalAdapter.findUserByEmail — the lookup /magic-link/verify resolves this very link with — matches on email.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 storedRecipientLocale failure 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).

  • 13 cases covering sendMagicLink alone — 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.
  • 2 cases covering the class — population stated in the test itself: the five sendTemplate sites, which is the whole of plugin-auth's mail surface for the reasons above, and the same five driveAllFive/TEMPLATES already 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 TERM with an absolute path and git 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; blob 574c9270…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:

AssertionError: expected [ 'ja-JP', 'ja-JP', 'zh-CN', …(2) ] to deeply equal [ 'ja-JP', 'ja-JP', 'zh-CN', …(2) ]
    "zh-CN",
-   "en-GB",
+   "es-ES",

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-object equals the HEAD blob 574c92703bef747011327133814a7ed60a8d064e (a non-empty hash — an empty one would be a failure, not a nothing-to-compare), and git diff HEAD is empty with a clean git status --porcelain.

Verification, all at the shipped commit

Gate family derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from 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 --commands so neither spelling nor section could be dropped. All 55 ran green at 291a73190, 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/") and check: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-loadsprovenance — entries/packages/cjsFiles/probes: this run 103/66/619/1 · floors 90/58/520/1
  • check-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 typecheck exit 0, with tsc echoed in the output rather than inferred from a zero — the run also carries check: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-gates prints outside its total ($RUNNER_TEMP / ${{ matrix.shard }} in argv — the tool refuses to invent a local invocation, and so do I); repo-wide pnpm 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.ts is shared with the #15948 change that landed as 8e0b29758 (already in this branch's base), but at a different member: that one moved the session payload's positions[]; this one touches a mail callback. #14641 (invitations) stays out of scope — and has since landed on its own.


Generated by Claude Code

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 2 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1a7a7c954b9142394c0b51975e3605360712af76packageMentionDocs.

Which tree this was computed on

This run read content/docs from c793bc24c53856d493a0fe82a2b6c580fad5d454 — the merge of head 291a7319060fdec20e924208aa8e24bc4ad1fd1b into base 1a7a7c954b9142394c0b51975e3605360712af76, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1a7a7c954b9142394c0b51975e3605360712af76 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: sendMagicLink is the one auth mail to an existing user that still ignores sys_user.locale (#14788 ruling D)

2 participants