fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail - #15107
Conversation
…and auth mail `sys_user.locale` has been a column since #13881 and user-writable since the 2026-09-03 ruling, but plugin-auth's own sends never read it: an OTP rendered in the deployment's language and auth mail in whichever language the triggering request asked for. An admin-initiated password reset therefore carried the ADMIN's browser language onto the user's mail. Both ladders now start one rung higher, in the order ruled for #14788 (option D, 2026-09-03): stored `sys_user.locale` -> the request's `Accept-Language` -> the deployment default. - Phone OTP SMS resolves the recipient by the unique `phone_number` and renders in their language. better-auth hands `sendOTP` / `sendPasswordResetOTP` only `{ phoneNumber, code }` (measured in the installed 1.7.x), so there is no request rung here and the chain collapses to stored -> deployment. - Auth mail (password reset, verification, change-email notice) reads the column off the recipient row it already identifies. The value at rest is normalized by `@objectstack/service-messaging`'s `normalizeRecipientLocale` -- the platform's one reader of that column, reused rather than copied, so its refusal of the stringified-nothing literals holds here too. The read is best-effort and can never fail a send. Invitations keep the deployment rung (#14641): an invitee has no `sys_user` row until acceptance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…c and vitest `check:type-source-resolution` and `check:test-source-alias` both refused the new value import: without wiring, plugin-auth's typecheck and its unit tests would render a verdict about the producer's last `pnpm build` rather than about its source in this checkout. - `tsconfig.json` gains one anchored `paths` rule for the bare name, and `rootDir` widens to `packages/` as its consequence -- the same shape `plugin-security` (#11184) and `packages/rest` (#9960) each record. Emit is unaffected: this package builds with tsup and typechecks with `--noEmit`. - `tsconfig.examples.json` widens its own `rootDir` for the same reason. - `vitest.config.ts` gains the matching anchored alias. Neither shrink-only registry is widened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
sys_user.locale for auth OTP SMS and auth mail
📓 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 37056d579bdf4eebcd4d380efcfed1b867e5b6e1 && git checkout 37056d579bdf4eebcd4d380efcfed1b867e5b6e1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4428dd5756750939e2fd503e431c34c1cb83a19c 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3 && git checkout -B drift-repro 4428dd5756750939e2fd503e431c34c1cb83a19c && git merge --no-ff 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3
node scripts/docs-audit/affected-docs.mjs --json 4428dd5756750939e2fd503e431c34c1cb83a19c
|
The "SMS text customisation & localisation" section said the locale follows
the deployment default. That ships false as of the previous commit, which gave
the OTP send the recipient's own `sys_user.locale` as its top rung.
Only the locale-resolution claim moved. The `sys_notification_template`
precedence rule, the `{{code}}` / `{{appName}}` / `{{minutes}}` and
`{{appName}}` / `{{loginUrl}}` / `{{baseUrl}}` holes, and the
best-effort/never-blocks-a-send sentence are untouched.
The `zh-CN -> zh -> en` fallback chain is kept verbatim: it is a DIFFERENT
mechanism from the rung ladder, and it did not move. Measured rather than
assumed -- `phoneSmsLocaleChain` is byte-identical to `origin/main`
(sha256 of the function body: 9a009ec7c14f4a79 on both). What changed is only
which locale is fed INTO that chain, so the sentence now says the chain
resolves whichever locale the ladder named.
The invitation SMS is called out separately because it does NOT gain the rung
-- an invitee's own column is #14641's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Landing — green after patch round 1, undrafted then armed
State on head Landing order observed: undraft first, No carrier is hung. Clause-② was declared no, and this seat verified the stated basis rather than accepting it: 0 added What patch round 1 corrected, and what it deliberately did not
⭐ The The docs sweep behind that correction gave every zero a positive control: On MERGED, Generated by Claude Code |
Fixes #14762
sys_user.localehas been a first-class column since #13881 and user-writable since PR #14958, but plugin-auth's own sends never read it. A phone OTP rendered in the deployment's language, and auth mail in whichever language the triggering request asked for — so an admin-initiated password reset carried the admin's browser language onto the user's mail.Both ladders now start one rung higher.
The ladder is ruled, not re-decided here
#14788 was ruled option D in the 2026-09-03 batch (maintainer verbatim: 「同意」):
The recorded reasoning: a value the user chose is stronger evidence of intent than the
Accept-Languagethe browser just sent. This PR implements that order and does not re-cost it. The card body's own suggestion — a stored rung between request and deployment — is the pre-ruling shape and is deliberately not what landed.For OTP SMS the chain collapses to stored -> deployment, confirmed against the code rather than assumed: better-auth 1.7.x calls both
sendOTPandsendPasswordResetOTPwith{ phoneNumber, code }and a ctx, so there is no request rung to rank on this surface.What changed
AuthManager.storedRecipientLocale(where)— one projectedfindOneonsys_user, under a system context, normalized. Best-effort by construction: no engine, no row, an unset column, a datasource outage or a value that cannot name a language all resolve to "no stored preference", never to a failed send.AuthManager.emailLocaleArg(requestSource, storedLocale)— three rungs instead of two. Threaded atsendResetPassword,sendVerificationEmailandsendChangeEmailNotice, the three sends that hold a recipient row.AuthManager.renderPhoneSmsBody(topic, data, storedLocale)— the OTP send resolves the recipient by the uniquephone_numberand renders in their language.setDefaultSmsLocale,setDefaultEmailLocale,sendChangeEmailNoticeandphone-sms-texts.ts— each said "auth mail does not read it yet", which this PR makes false.Everything underneath is untouched: an account with no stored locale still gets the deployment default, and with nothing configured the documented
en-USfloor and the built-inenSMS row still apply. Nothing dead-letters.Reuse, measured before depending on it
The dispatch flagged an earlier triage reading of
git grep -c "normalizeRecipientLocale" -- packages/services/service-messaging/src/-> 0, taken at a pre-#13881 ref. Re-measured at currentorigin/main, with a positive control first so a zero would be a live reading rather than a dead channel:It exists and it is exported, so it is imported rather than copied. Its refusal of the stringified-nothing literals is load-bearing here and is pinned: without it,
normalizeAuthEmailLocale('undefined')returns'undefined'verbatim and names a row that does not exist.@objectstack/plugin-auththerefore declares@objectstack/service-messagingas a dependency. Precedent:@objectstack/plugin-webhooksalready depends on it; no manifest cycle (check:workspace-manifest-cyclesgreen).The premise the card got wrong, and what replaced it
The card's suggested shape says to read the column "off the row
sendPhoneOtpalready has". There is no such row, and no such method.deliverPhoneOtp(phone, code)is what exists, and better-auth hands its callbacks a phone number and a code —request-password-resetresolves the user for its own gate and does not pass it on (measured in the installed 1.7.xroutes.mjs).So the row is looked up here: one read on the unique
phone_numberindex, paid only once the send is going to happen. The number is matched exactly, which is how better-auth matches it for its own user lookup on the same route; a differently formatted spelling resolves no row and lands on the deployment default, which is the documented floor rather than a failure.The card's core premise — the column exists and plugin-auth does not read it — holds, and is quoted by the shipped source's own comments.
Deliberately out of scope
Invitations. Mail (
sendInvitationEmail) and SMS (sendPhoneInviteSms) both keep the deployment rung; an invitee has nosys_userrow until acceptance. That is #14641's, and both abstentions are pinned rather than merely described.sendMagicLinkis handed only{ email, url, token }, so its stored rung needs a lookup keyed by email — a query shape on a path this card does not name. Filed as #15106 rather than smuggled in.Pins, and the ablation
Nine new SMS pins and eleven new mail pins. Both fixture locales render genuinely different text in both directions (
验证码only in the zh row,verification codeonly in the en row), and the three-rung mail pin gives each rung a different locale (storedja-JP/ headerzh-CN/ deploymentes-ES), so no assertion can pass on a rung it did not measure. Two pins assert the mechanism — that the read happened, on the right predicate, with the right projection, under a system context — so a body assertion cannot be satisfied by some other resolution step reaching the same answer by luck.Ablation. With the implementation committed first (so the restore leg has a real reference), both rungs were reverted in place and the mutation proved on disk before measuring — injected marker count 2, both deleted anchors at 0, blob
3b6add63->41febcbe. No rebuild leg is owed: both suites import./auth-managerby relative source path, so the mutated source is what vitest ran. Restore proved the same way:git diff HEADempty,git status --porcelainempty, on-disk blob back to3b6add63c4a4006266351d79fec6e9339ed40d5e, zero residual markers.auth-email-locale.test.tsauth-manager.test.tsNo divergence. The floor pins staying green is the intended result — they assert the behaviour the ablation restores.
Clause-②: no
The diff touches no
packages/spec/src/**, no*.zod.tscontract schema and no error-code ledger; it adds no exported symbol to any package's public API (both new helpers areprivate, andemailLocaleArg's new parameter is on a private method); and it changes no accept/reject behaviour — a locale ladder is a resolution preference, and no input is newly accepted or newly refused at any boundary. The one published-metadata movement is the new workspace dependency noted above, which is not a contract surface. Flagging it explicitly so a reviewer who reads that differently can say so.packages/specis untouched.content/docs/releases/**is untouched. No test is skipped, disabled or quarantined.Verification — at
c2f8324(final commit), tree cleanEvery reading below was taken at this exact tree, with the exit code captured before any pipe.
pnpm --filter '@objectstack/plugin-auth^...' build— dependency closure built first, so nothing below reads a staledist. Exit 0.pnpm --filter @objectstack/plugin-auth test— 93 files / 1958 tests passed, exit 0.pnpm --filter @objectstack/plugin-auth typecheck— exit 0, all three programs. Itscheck:test-typecheckstep is what makes this cover the new test files:10 file(s) / 94 error(s) / 23 pinned signature(s)held, unmoved.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, derived from the real change set rather than a hand-written diff): 47 green.node scripts/check-test-completeness.mjsanswered exit 3 = NOT MEASURED, not a pass and not a red: it grades a savedturbo run testlog and none exists locally. Unreachable in CI, which tees the log and passes the path. This diff cannot move it.check:type-check-debt --re-measure—17 ledger entries re-measured, 217 raw tsc errors, none above its recorded number, surplus: none.check:dual-build-cjs-loads—102 require entry points across 66 packages load.check:test-source-aliasandcheck:type-source-resolutionboth refused the new cross-package value import until tsc and vitest resolve it from source. Fixed by wiring, never by widening either shrink-only registry.eslint --no-inline-config --format jsonover the five authored files this diff touches: 5 files linted, 0 errors, 0 warnings (count read from the JSON, not from prose). The narrowing is sound because this repo runs ONEeslint.config.mjswhich never enables type-aware linting for any file — noparserOptions.project, no typed@typescript-eslintrules, a fact that config records at line 328 with its own positive control. With no cross-file type information in play, this diff cannot move the verdict on a file it does not touch. The remaining four files it changes (package.json, twotsconfig*.json, one changeset) fall outside everyfilesglob in that config. CI runs the full sweep regardless.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code
Generated by Claude Code