fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail - #15107

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs
Sep 3, 2026
Merged

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail#15107
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14762

sys_user.locale has 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: 「同意」):

sys_user.locale when set -> the request's Accept-Language -> the deployment default.

The recorded reasoning: a value the user chose is stronger evidence of intent than the Accept-Language the 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 sendOTP and sendPasswordResetOTP with { phoneNumber, code } and a ctx, so there is no request rung to rank on this surface.

What changed

  • AuthManager.storedRecipientLocale(where) — one projected findOne on sys_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 at sendResetPassword, sendVerificationEmail and sendChangeEmailNotice, the three sends that hold a recipient row.
  • AuthManager.renderPhoneSmsBody(topic, data, storedLocale) — the OTP send resolves the recipient by the unique phone_number and renders in their language.
  • Stale ladder docblocks on setDefaultSmsLocale, setDefaultEmailLocale, sendChangeEmailNotice and phone-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-US floor and the built-in en SMS 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 current origin/main, with a positive control first so a zero would be a live reading rather than a dead channel:

git grep -c "locale" origin/main -- packages/services/service-messaging/src/
-> 21 files (positive control: the channel is live)
git grep -c "normalizeRecipientLocale" origin/main -- packages/services/service-messaging/src/
-> recipient-locale.ts:5, recipient-locale.test.ts:12,
recipient-locale-shape-parity.test.ts:3, index.ts:1
git grep -n "normalizeRecipientLocale" origin/main -- .../src/index.ts
-> index.ts:68 (exported)

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-auth therefore declares @objectstack/service-messaging as a dependency. Precedent: @objectstack/plugin-webhooks already depends on it; no manifest cycle (check:workspace-manifest-cycles green).

The premise the card got wrong, and what replaced it

The card's suggested shape says to read the column "off the row sendPhoneOtp already 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-reset resolves the user for its own gate and does not pass it on (measured in the installed 1.7.x routes.mjs).

So the row is looked up here: one read on the unique phone_number index, 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 no sys_user row until acceptance. That is #14641's, and both abstentions are pinned rather than merely described.

sendMagicLink is 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 code only in the en row), and the three-rung mail pin gives each rung a different locale (stored ja-JP / header zh-CN / deployment es-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-manager by relative source path, so the mutated source is what vitest ran. Restore proved the same way: git diff HEAD empty, git status --porcelain empty, on-disk blob back to 3b6add63c4a4006266351d79fec6e9339ed40d5e, zero residual markers.

predictedmeasured
red55
auth-email-locale.test.tsoutranks-BOTH · catalog-language mapping · unshipped-tag passthroughsame 3, by name
auth-manager.test.tszh-CN user on en-US deployment · the reversesame 2, by name
green under ablationthe four floor pins and the two mechanism pins in each file311 passed

No 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.ts contract schema and no error-code ledger; it adds no exported symbol to any package's public API (both new helpers are private, and emailLocaleArg'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/spec is untouched. content/docs/releases/** is untouched. No test is skipped, disabled or quarantined.

Verification — at c2f8324 (final commit), tree clean

Every 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 stale dist. Exit 0.
  • pnpm --filter @objectstack/plugin-auth test93 files / 1958 tests passed, exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck — exit 0, all three programs. Its check:test-typecheck step is what makes this cover the new test files: 10 file(s) / 94 error(s) / 23 pinned signature(s) held, unmoved.
  • All 48 derived gate families (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.mjs answered exit 3 = NOT MEASURED, not a pass and not a red: it grades a saved turbo run test log and none exists locally. Unreachable in CI, which tees the log and passes the path. This diff cannot move it.
    • The ratchet families were re-run against the built workspace closure: check:type-check-debt --re-measure17 ledger entries re-measured, 217 raw tsc errors, none above its recorded number, surplus: none. check:dual-build-cjs-loads102 require entry points across 66 packages load.
    • Two gates were red first and are the reason the two config commits exist: check:test-source-alias and check:type-source-resolution both 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 — a declared narrowing, not a skip.eslint --no-inline-config --format json over 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 ONE eslint.config.mjs which never enables type-aware linting for any file — no parserOptions.project, no typed @typescript-eslint rules, 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, two tsconfig*.json, one changeset) fall outside every files glob 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

…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
@claudeclaudeBot changed the title fix(auth): read the recipient own sys_user.locale for auth OTP SMS and auth mailfix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mailSep 3, 2026
@github-actionsgithub-actionsBot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 7 documentable anchor(s). ⚠️5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 37056d579bdf4eebcd4d380efcfed1b867e5b6e1 — the merge of head 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 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

⚠️ 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 4428dd5756750939e2fd503e431c34c1cb83a19c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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
@os-sales
os-sales marked this pull request as ready for review September 3, 2026 23:26
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — green after patch round 1, undrafted then armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

State on head 19c2aed981: all checks complete, 0 failures, read at job level with the latest run taken per check name (Test Core is an aggregator over its shards and is not a substitute for reading them).

Landing order observed: undraft first, draft=false confirmed on read-back, then auto-merge armed at 23:26:09Z. Auto-merge does not survive a draft conversion, so the order is load-bearing. ⛔ One re-arm maximum, unspent.

No carrier is hung. Clause-② was declared no, and this seat verified the stated basis rather than accepting it: 0 added export lines across 574 added lines, with all three new helpers (storedRecipientLocale, emailLocaleArg, renderPhoneSmsBody) declared private.

What patch round 1 corrected, and what it deliberately did not

content/docs/permissions/authentication.mdx stated the OTP SMS locale "follows the deployment default", which this change makes false. The page now distinguishes the two SMS sends — the OTP takes the recipient's own sys_user.locale when the account has one (matched on phone_number, so an unmatched number takes the deployment default too), while the invitation SMS still reads the deployment default alone, since invitations are #14641's and untouched here.

⭐ The zh-CN → zh → en fallback chain was measured before being left alone, not assumed: phoneSmsLocaleChain is byte-identical to origin/main (sha256 9a009ec7c14f4a79 on both refs). It is a different mechanism from the rung ladder, so the corrected sentence says the chain resolves whichever locale the ladder named rather than restating it. The template-precedence rule, the hole lists and the best-effort sentence are untouched.

The docs sweep behind that correction gave every zero a positive control: localization.locale → exactly 1 hit (this page); setDefaultEmailLocale|setDefaultSmsLocale → 0 (control: three sibling greps non-zero on the same corpus); sys_user.locale → 2, both the messaging path #13881 already shipped correctly; Accept-Language → 8, all request-rendering or UI translation.

On MERGED, pm:dispatched comes off card #14762.


Generated by Claude Code

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

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail - #15107

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs
Sep 3, 2026
Merged

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail#15107
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14762

sys_user.locale has 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: 「同意」):

sys_user.locale when set -> the request's Accept-Language -> the deployment default.

The recorded reasoning: a value the user chose is stronger evidence of intent than the Accept-Language the 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 sendOTP and sendPasswordResetOTP with { phoneNumber, code } and a ctx, so there is no request rung to rank on this surface.

What changed

  • AuthManager.storedRecipientLocale(where) — one projected findOne on sys_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 at sendResetPassword, sendVerificationEmail and sendChangeEmailNotice, the three sends that hold a recipient row.
  • AuthManager.renderPhoneSmsBody(topic, data, storedLocale) — the OTP send resolves the recipient by the unique phone_number and renders in their language.
  • Stale ladder docblocks on setDefaultSmsLocale, setDefaultEmailLocale, sendChangeEmailNotice and phone-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-US floor and the built-in en SMS 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 current origin/main, with a positive control first so a zero would be a live reading rather than a dead channel:

git grep -c "locale" origin/main -- packages/services/service-messaging/src/
-> 21 files (positive control: the channel is live)
git grep -c "normalizeRecipientLocale" origin/main -- packages/services/service-messaging/src/
-> recipient-locale.ts:5, recipient-locale.test.ts:12,
recipient-locale-shape-parity.test.ts:3, index.ts:1
git grep -n "normalizeRecipientLocale" origin/main -- .../src/index.ts
-> index.ts:68 (exported)

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-auth therefore declares @objectstack/service-messaging as a dependency. Precedent: @objectstack/plugin-webhooks already depends on it; no manifest cycle (check:workspace-manifest-cycles green).

The premise the card got wrong, and what replaced it

The card's suggested shape says to read the column "off the row sendPhoneOtp already 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-reset resolves the user for its own gate and does not pass it on (measured in the installed 1.7.x routes.mjs).

So the row is looked up here: one read on the unique phone_number index, 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 no sys_user row until acceptance. That is #14641's, and both abstentions are pinned rather than merely described.

sendMagicLink is 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 code only in the en row), and the three-rung mail pin gives each rung a different locale (stored ja-JP / header zh-CN / deployment es-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-manager by relative source path, so the mutated source is what vitest ran. Restore proved the same way: git diff HEAD empty, git status --porcelain empty, on-disk blob back to 3b6add63c4a4006266351d79fec6e9339ed40d5e, zero residual markers.

predictedmeasured
red55
auth-email-locale.test.tsoutranks-BOTH · catalog-language mapping · unshipped-tag passthroughsame 3, by name
auth-manager.test.tszh-CN user on en-US deployment · the reversesame 2, by name
green under ablationthe four floor pins and the two mechanism pins in each file311 passed

No 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.ts contract schema and no error-code ledger; it adds no exported symbol to any package's public API (both new helpers are private, and emailLocaleArg'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/spec is untouched. content/docs/releases/** is untouched. No test is skipped, disabled or quarantined.

Verification — at c2f8324 (final commit), tree clean

Every 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 stale dist. Exit 0.
  • pnpm --filter @objectstack/plugin-auth test93 files / 1958 tests passed, exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck — exit 0, all three programs. Its check:test-typecheck step is what makes this cover the new test files: 10 file(s) / 94 error(s) / 23 pinned signature(s) held, unmoved.
  • All 48 derived gate families (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.mjs answered exit 3 = NOT MEASURED, not a pass and not a red: it grades a saved turbo run test log and none exists locally. Unreachable in CI, which tees the log and passes the path. This diff cannot move it.
    • The ratchet families were re-run against the built workspace closure: check:type-check-debt --re-measure17 ledger entries re-measured, 217 raw tsc errors, none above its recorded number, surplus: none. check:dual-build-cjs-loads102 require entry points across 66 packages load.
    • Two gates were red first and are the reason the two config commits exist: check:test-source-alias and check:type-source-resolution both 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 — a declared narrowing, not a skip.eslint --no-inline-config --format json over 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 ONE eslint.config.mjs which never enables type-aware linting for any file — no parserOptions.project, no typed @typescript-eslint rules, 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, two tsconfig*.json, one changeset) fall outside every files glob 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

…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
@claudeclaudeBot changed the title fix(auth): read the recipient own sys_user.locale for auth OTP SMS and auth mailfix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mailSep 3, 2026
@github-actionsgithub-actionsBot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 7 documentable anchor(s). ⚠️5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 37056d579bdf4eebcd4d380efcfed1b867e5b6e1 — the merge of head 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 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

⚠️ 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 4428dd5756750939e2fd503e431c34c1cb83a19c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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
@os-sales
os-sales marked this pull request as ready for review September 3, 2026 23:26
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — green after patch round 1, undrafted then armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

State on head 19c2aed981: all checks complete, 0 failures, read at job level with the latest run taken per check name (Test Core is an aggregator over its shards and is not a substitute for reading them).

Landing order observed: undraft first, draft=false confirmed on read-back, then auto-merge armed at 23:26:09Z. Auto-merge does not survive a draft conversion, so the order is load-bearing. ⛔ One re-arm maximum, unspent.

No carrier is hung. Clause-② was declared no, and this seat verified the stated basis rather than accepting it: 0 added export lines across 574 added lines, with all three new helpers (storedRecipientLocale, emailLocaleArg, renderPhoneSmsBody) declared private.

What patch round 1 corrected, and what it deliberately did not

content/docs/permissions/authentication.mdx stated the OTP SMS locale "follows the deployment default", which this change makes false. The page now distinguishes the two SMS sends — the OTP takes the recipient's own sys_user.locale when the account has one (matched on phone_number, so an unmatched number takes the deployment default too), while the invitation SMS still reads the deployment default alone, since invitations are #14641's and untouched here.

⭐ The zh-CN → zh → en fallback chain was measured before being left alone, not assumed: phoneSmsLocaleChain is byte-identical to origin/main (sha256 9a009ec7c14f4a79 on both refs). It is a different mechanism from the rung ladder, so the corrected sentence says the chain resolves whichever locale the ladder named rather than restating it. The template-precedence rule, the hole lists and the best-effort sentence are untouched.

The docs sweep behind that correction gave every zero a positive control: localization.locale → exactly 1 hit (this page); setDefaultEmailLocale|setDefaultSmsLocale → 0 (control: three sibling greps non-zero on the same corpus); sys_user.locale → 2, both the messaging path #13881 already shipped correctly; Accept-Language → 8, all request-rendering or UI translation.

On MERGED, pm:dispatched comes off card #14762.


Generated by Claude Code

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

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail - #15107

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs
Sep 3, 2026
Merged

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail#15107
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14762

sys_user.locale has 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: 「同意」):

sys_user.locale when set -> the request's Accept-Language -> the deployment default.

The recorded reasoning: a value the user chose is stronger evidence of intent than the Accept-Language the 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 sendOTP and sendPasswordResetOTP with { phoneNumber, code } and a ctx, so there is no request rung to rank on this surface.

What changed

  • AuthManager.storedRecipientLocale(where) — one projected findOne on sys_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 at sendResetPassword, sendVerificationEmail and sendChangeEmailNotice, the three sends that hold a recipient row.
  • AuthManager.renderPhoneSmsBody(topic, data, storedLocale) — the OTP send resolves the recipient by the unique phone_number and renders in their language.
  • Stale ladder docblocks on setDefaultSmsLocale, setDefaultEmailLocale, sendChangeEmailNotice and phone-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-US floor and the built-in en SMS 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 current origin/main, with a positive control first so a zero would be a live reading rather than a dead channel:

git grep -c "locale" origin/main -- packages/services/service-messaging/src/
-> 21 files (positive control: the channel is live)
git grep -c "normalizeRecipientLocale" origin/main -- packages/services/service-messaging/src/
-> recipient-locale.ts:5, recipient-locale.test.ts:12,
recipient-locale-shape-parity.test.ts:3, index.ts:1
git grep -n "normalizeRecipientLocale" origin/main -- .../src/index.ts
-> index.ts:68 (exported)

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-auth therefore declares @objectstack/service-messaging as a dependency. Precedent: @objectstack/plugin-webhooks already depends on it; no manifest cycle (check:workspace-manifest-cycles green).

The premise the card got wrong, and what replaced it

The card's suggested shape says to read the column "off the row sendPhoneOtp already 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-reset resolves the user for its own gate and does not pass it on (measured in the installed 1.7.x routes.mjs).

So the row is looked up here: one read on the unique phone_number index, 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 no sys_user row until acceptance. That is #14641's, and both abstentions are pinned rather than merely described.

sendMagicLink is 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 code only in the en row), and the three-rung mail pin gives each rung a different locale (stored ja-JP / header zh-CN / deployment es-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-manager by relative source path, so the mutated source is what vitest ran. Restore proved the same way: git diff HEAD empty, git status --porcelain empty, on-disk blob back to 3b6add63c4a4006266351d79fec6e9339ed40d5e, zero residual markers.

predictedmeasured
red55
auth-email-locale.test.tsoutranks-BOTH · catalog-language mapping · unshipped-tag passthroughsame 3, by name
auth-manager.test.tszh-CN user on en-US deployment · the reversesame 2, by name
green under ablationthe four floor pins and the two mechanism pins in each file311 passed

No 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.ts contract schema and no error-code ledger; it adds no exported symbol to any package's public API (both new helpers are private, and emailLocaleArg'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/spec is untouched. content/docs/releases/** is untouched. No test is skipped, disabled or quarantined.

Verification — at c2f8324 (final commit), tree clean

Every 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 stale dist. Exit 0.
  • pnpm --filter @objectstack/plugin-auth test93 files / 1958 tests passed, exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck — exit 0, all three programs. Its check:test-typecheck step is what makes this cover the new test files: 10 file(s) / 94 error(s) / 23 pinned signature(s) held, unmoved.
  • All 48 derived gate families (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.mjs answered exit 3 = NOT MEASURED, not a pass and not a red: it grades a saved turbo run test log and none exists locally. Unreachable in CI, which tees the log and passes the path. This diff cannot move it.
    • The ratchet families were re-run against the built workspace closure: check:type-check-debt --re-measure17 ledger entries re-measured, 217 raw tsc errors, none above its recorded number, surplus: none. check:dual-build-cjs-loads102 require entry points across 66 packages load.
    • Two gates were red first and are the reason the two config commits exist: check:test-source-alias and check:type-source-resolution both 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 — a declared narrowing, not a skip.eslint --no-inline-config --format json over 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 ONE eslint.config.mjs which never enables type-aware linting for any file — no parserOptions.project, no typed @typescript-eslint rules, 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, two tsconfig*.json, one changeset) fall outside every files glob 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

…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
@claudeclaudeBot changed the title fix(auth): read the recipient own sys_user.locale for auth OTP SMS and auth mailfix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mailSep 3, 2026
@github-actionsgithub-actionsBot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 7 documentable anchor(s). ⚠️5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 37056d579bdf4eebcd4d380efcfed1b867e5b6e1 — the merge of head 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 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

⚠️ 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 4428dd5756750939e2fd503e431c34c1cb83a19c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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
@os-sales
os-sales marked this pull request as ready for review September 3, 2026 23:26
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — green after patch round 1, undrafted then armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

State on head 19c2aed981: all checks complete, 0 failures, read at job level with the latest run taken per check name (Test Core is an aggregator over its shards and is not a substitute for reading them).

Landing order observed: undraft first, draft=false confirmed on read-back, then auto-merge armed at 23:26:09Z. Auto-merge does not survive a draft conversion, so the order is load-bearing. ⛔ One re-arm maximum, unspent.

No carrier is hung. Clause-② was declared no, and this seat verified the stated basis rather than accepting it: 0 added export lines across 574 added lines, with all three new helpers (storedRecipientLocale, emailLocaleArg, renderPhoneSmsBody) declared private.

What patch round 1 corrected, and what it deliberately did not

content/docs/permissions/authentication.mdx stated the OTP SMS locale "follows the deployment default", which this change makes false. The page now distinguishes the two SMS sends — the OTP takes the recipient's own sys_user.locale when the account has one (matched on phone_number, so an unmatched number takes the deployment default too), while the invitation SMS still reads the deployment default alone, since invitations are #14641's and untouched here.

⭐ The zh-CN → zh → en fallback chain was measured before being left alone, not assumed: phoneSmsLocaleChain is byte-identical to origin/main (sha256 9a009ec7c14f4a79 on both refs). It is a different mechanism from the rung ladder, so the corrected sentence says the chain resolves whichever locale the ladder named rather than restating it. The template-precedence rule, the hole lists and the best-effort sentence are untouched.

The docs sweep behind that correction gave every zero a positive control: localization.locale → exactly 1 hit (this page); setDefaultEmailLocale|setDefaultSmsLocale → 0 (control: three sibling greps non-zero on the same corpus); sys_user.locale → 2, both the messaging path #13881 already shipped correctly; Accept-Language → 8, all request-rendering or UI translation.

On MERGED, pm:dispatched comes off card #14762.


Generated by Claude Code

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

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail - #15107

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs
Sep 3, 2026
Merged

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail#15107
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14762

sys_user.locale has 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: 「同意」):

sys_user.locale when set -> the request's Accept-Language -> the deployment default.

The recorded reasoning: a value the user chose is stronger evidence of intent than the Accept-Language the 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 sendOTP and sendPasswordResetOTP with { phoneNumber, code } and a ctx, so there is no request rung to rank on this surface.

What changed

  • AuthManager.storedRecipientLocale(where) — one projected findOne on sys_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 at sendResetPassword, sendVerificationEmail and sendChangeEmailNotice, the three sends that hold a recipient row.
  • AuthManager.renderPhoneSmsBody(topic, data, storedLocale) — the OTP send resolves the recipient by the unique phone_number and renders in their language.
  • Stale ladder docblocks on setDefaultSmsLocale, setDefaultEmailLocale, sendChangeEmailNotice and phone-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-US floor and the built-in en SMS 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 current origin/main, with a positive control first so a zero would be a live reading rather than a dead channel:

git grep -c "locale" origin/main -- packages/services/service-messaging/src/
-> 21 files (positive control: the channel is live)
git grep -c "normalizeRecipientLocale" origin/main -- packages/services/service-messaging/src/
-> recipient-locale.ts:5, recipient-locale.test.ts:12,
recipient-locale-shape-parity.test.ts:3, index.ts:1
git grep -n "normalizeRecipientLocale" origin/main -- .../src/index.ts
-> index.ts:68 (exported)

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-auth therefore declares @objectstack/service-messaging as a dependency. Precedent: @objectstack/plugin-webhooks already depends on it; no manifest cycle (check:workspace-manifest-cycles green).

The premise the card got wrong, and what replaced it

The card's suggested shape says to read the column "off the row sendPhoneOtp already 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-reset resolves the user for its own gate and does not pass it on (measured in the installed 1.7.x routes.mjs).

So the row is looked up here: one read on the unique phone_number index, 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 no sys_user row until acceptance. That is #14641's, and both abstentions are pinned rather than merely described.

sendMagicLink is 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 code only in the en row), and the three-rung mail pin gives each rung a different locale (stored ja-JP / header zh-CN / deployment es-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-manager by relative source path, so the mutated source is what vitest ran. Restore proved the same way: git diff HEAD empty, git status --porcelain empty, on-disk blob back to 3b6add63c4a4006266351d79fec6e9339ed40d5e, zero residual markers.

predictedmeasured
red55
auth-email-locale.test.tsoutranks-BOTH · catalog-language mapping · unshipped-tag passthroughsame 3, by name
auth-manager.test.tszh-CN user on en-US deployment · the reversesame 2, by name
green under ablationthe four floor pins and the two mechanism pins in each file311 passed

No 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.ts contract schema and no error-code ledger; it adds no exported symbol to any package's public API (both new helpers are private, and emailLocaleArg'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/spec is untouched. content/docs/releases/** is untouched. No test is skipped, disabled or quarantined.

Verification — at c2f8324 (final commit), tree clean

Every 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 stale dist. Exit 0.
  • pnpm --filter @objectstack/plugin-auth test93 files / 1958 tests passed, exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck — exit 0, all three programs. Its check:test-typecheck step is what makes this cover the new test files: 10 file(s) / 94 error(s) / 23 pinned signature(s) held, unmoved.
  • All 48 derived gate families (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.mjs answered exit 3 = NOT MEASURED, not a pass and not a red: it grades a saved turbo run test log and none exists locally. Unreachable in CI, which tees the log and passes the path. This diff cannot move it.
    • The ratchet families were re-run against the built workspace closure: check:type-check-debt --re-measure17 ledger entries re-measured, 217 raw tsc errors, none above its recorded number, surplus: none. check:dual-build-cjs-loads102 require entry points across 66 packages load.
    • Two gates were red first and are the reason the two config commits exist: check:test-source-alias and check:type-source-resolution both 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 — a declared narrowing, not a skip.eslint --no-inline-config --format json over 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 ONE eslint.config.mjs which never enables type-aware linting for any file — no parserOptions.project, no typed @typescript-eslint rules, 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, two tsconfig*.json, one changeset) fall outside every files glob 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

…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
@claudeclaudeBot changed the title fix(auth): read the recipient own sys_user.locale for auth OTP SMS and auth mailfix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mailSep 3, 2026
@github-actionsgithub-actionsBot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 7 documentable anchor(s). ⚠️5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 37056d579bdf4eebcd4d380efcfed1b867e5b6e1 — the merge of head 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 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

⚠️ 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 4428dd5756750939e2fd503e431c34c1cb83a19c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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
@os-sales
os-sales marked this pull request as ready for review September 3, 2026 23:26
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — green after patch round 1, undrafted then armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

State on head 19c2aed981: all checks complete, 0 failures, read at job level with the latest run taken per check name (Test Core is an aggregator over its shards and is not a substitute for reading them).

Landing order observed: undraft first, draft=false confirmed on read-back, then auto-merge armed at 23:26:09Z. Auto-merge does not survive a draft conversion, so the order is load-bearing. ⛔ One re-arm maximum, unspent.

No carrier is hung. Clause-② was declared no, and this seat verified the stated basis rather than accepting it: 0 added export lines across 574 added lines, with all three new helpers (storedRecipientLocale, emailLocaleArg, renderPhoneSmsBody) declared private.

What patch round 1 corrected, and what it deliberately did not

content/docs/permissions/authentication.mdx stated the OTP SMS locale "follows the deployment default", which this change makes false. The page now distinguishes the two SMS sends — the OTP takes the recipient's own sys_user.locale when the account has one (matched on phone_number, so an unmatched number takes the deployment default too), while the invitation SMS still reads the deployment default alone, since invitations are #14641's and untouched here.

⭐ The zh-CN → zh → en fallback chain was measured before being left alone, not assumed: phoneSmsLocaleChain is byte-identical to origin/main (sha256 9a009ec7c14f4a79 on both refs). It is a different mechanism from the rung ladder, so the corrected sentence says the chain resolves whichever locale the ladder named rather than restating it. The template-precedence rule, the hole lists and the best-effort sentence are untouched.

The docs sweep behind that correction gave every zero a positive control: localization.locale → exactly 1 hit (this page); setDefaultEmailLocale|setDefaultSmsLocale → 0 (control: three sibling greps non-zero on the same corpus); sys_user.locale → 2, both the messaging path #13881 already shipped correctly; Accept-Language → 8, all request-rendering or UI translation.

On MERGED, pm:dispatched comes off card #14762.


Generated by Claude Code

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

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail - #15107

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs
Sep 3, 2026
Merged

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail#15107
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14762

sys_user.locale has 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: 「同意」):

sys_user.locale when set -> the request's Accept-Language -> the deployment default.

The recorded reasoning: a value the user chose is stronger evidence of intent than the Accept-Language the 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 sendOTP and sendPasswordResetOTP with { phoneNumber, code } and a ctx, so there is no request rung to rank on this surface.

What changed

  • AuthManager.storedRecipientLocale(where) — one projected findOne on sys_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 at sendResetPassword, sendVerificationEmail and sendChangeEmailNotice, the three sends that hold a recipient row.
  • AuthManager.renderPhoneSmsBody(topic, data, storedLocale) — the OTP send resolves the recipient by the unique phone_number and renders in their language.
  • Stale ladder docblocks on setDefaultSmsLocale, setDefaultEmailLocale, sendChangeEmailNotice and phone-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-US floor and the built-in en SMS 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 current origin/main, with a positive control first so a zero would be a live reading rather than a dead channel:

git grep -c "locale" origin/main -- packages/services/service-messaging/src/
-> 21 files (positive control: the channel is live)
git grep -c "normalizeRecipientLocale" origin/main -- packages/services/service-messaging/src/
-> recipient-locale.ts:5, recipient-locale.test.ts:12,
recipient-locale-shape-parity.test.ts:3, index.ts:1
git grep -n "normalizeRecipientLocale" origin/main -- .../src/index.ts
-> index.ts:68 (exported)

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-auth therefore declares @objectstack/service-messaging as a dependency. Precedent: @objectstack/plugin-webhooks already depends on it; no manifest cycle (check:workspace-manifest-cycles green).

The premise the card got wrong, and what replaced it

The card's suggested shape says to read the column "off the row sendPhoneOtp already 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-reset resolves the user for its own gate and does not pass it on (measured in the installed 1.7.x routes.mjs).

So the row is looked up here: one read on the unique phone_number index, 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 no sys_user row until acceptance. That is #14641's, and both abstentions are pinned rather than merely described.

sendMagicLink is 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 code only in the en row), and the three-rung mail pin gives each rung a different locale (stored ja-JP / header zh-CN / deployment es-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-manager by relative source path, so the mutated source is what vitest ran. Restore proved the same way: git diff HEAD empty, git status --porcelain empty, on-disk blob back to 3b6add63c4a4006266351d79fec6e9339ed40d5e, zero residual markers.

predictedmeasured
red55
auth-email-locale.test.tsoutranks-BOTH · catalog-language mapping · unshipped-tag passthroughsame 3, by name
auth-manager.test.tszh-CN user on en-US deployment · the reversesame 2, by name
green under ablationthe four floor pins and the two mechanism pins in each file311 passed

No 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.ts contract schema and no error-code ledger; it adds no exported symbol to any package's public API (both new helpers are private, and emailLocaleArg'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/spec is untouched. content/docs/releases/** is untouched. No test is skipped, disabled or quarantined.

Verification — at c2f8324 (final commit), tree clean

Every 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 stale dist. Exit 0.
  • pnpm --filter @objectstack/plugin-auth test93 files / 1958 tests passed, exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck — exit 0, all three programs. Its check:test-typecheck step is what makes this cover the new test files: 10 file(s) / 94 error(s) / 23 pinned signature(s) held, unmoved.
  • All 48 derived gate families (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.mjs answered exit 3 = NOT MEASURED, not a pass and not a red: it grades a saved turbo run test log and none exists locally. Unreachable in CI, which tees the log and passes the path. This diff cannot move it.
    • The ratchet families were re-run against the built workspace closure: check:type-check-debt --re-measure17 ledger entries re-measured, 217 raw tsc errors, none above its recorded number, surplus: none. check:dual-build-cjs-loads102 require entry points across 66 packages load.
    • Two gates were red first and are the reason the two config commits exist: check:test-source-alias and check:type-source-resolution both 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 — a declared narrowing, not a skip.eslint --no-inline-config --format json over 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 ONE eslint.config.mjs which never enables type-aware linting for any file — no parserOptions.project, no typed @typescript-eslint rules, 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, two tsconfig*.json, one changeset) fall outside every files glob 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

…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
@claudeclaudeBot changed the title fix(auth): read the recipient own sys_user.locale for auth OTP SMS and auth mailfix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mailSep 3, 2026
@github-actionsgithub-actionsBot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 7 documentable anchor(s). ⚠️5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 37056d579bdf4eebcd4d380efcfed1b867e5b6e1 — the merge of head 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 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

⚠️ 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 4428dd5756750939e2fd503e431c34c1cb83a19c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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
@os-sales
os-sales marked this pull request as ready for review September 3, 2026 23:26
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — green after patch round 1, undrafted then armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

State on head 19c2aed981: all checks complete, 0 failures, read at job level with the latest run taken per check name (Test Core is an aggregator over its shards and is not a substitute for reading them).

Landing order observed: undraft first, draft=false confirmed on read-back, then auto-merge armed at 23:26:09Z. Auto-merge does not survive a draft conversion, so the order is load-bearing. ⛔ One re-arm maximum, unspent.

No carrier is hung. Clause-② was declared no, and this seat verified the stated basis rather than accepting it: 0 added export lines across 574 added lines, with all three new helpers (storedRecipientLocale, emailLocaleArg, renderPhoneSmsBody) declared private.

What patch round 1 corrected, and what it deliberately did not

content/docs/permissions/authentication.mdx stated the OTP SMS locale "follows the deployment default", which this change makes false. The page now distinguishes the two SMS sends — the OTP takes the recipient's own sys_user.locale when the account has one (matched on phone_number, so an unmatched number takes the deployment default too), while the invitation SMS still reads the deployment default alone, since invitations are #14641's and untouched here.

⭐ The zh-CN → zh → en fallback chain was measured before being left alone, not assumed: phoneSmsLocaleChain is byte-identical to origin/main (sha256 9a009ec7c14f4a79 on both refs). It is a different mechanism from the rung ladder, so the corrected sentence says the chain resolves whichever locale the ladder named rather than restating it. The template-precedence rule, the hole lists and the best-effort sentence are untouched.

The docs sweep behind that correction gave every zero a positive control: localization.locale → exactly 1 hit (this page); setDefaultEmailLocale|setDefaultSmsLocale → 0 (control: three sibling greps non-zero on the same corpus); sys_user.locale → 2, both the messaging path #13881 already shipped correctly; Accept-Language → 8, all request-rendering or UI translation.

On MERGED, pm:dispatched comes off card #14762.


Generated by Claude Code

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

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail - #15107

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs
Sep 3, 2026
Merged

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail#15107
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14762

sys_user.locale has 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: 「同意」):

sys_user.locale when set -> the request's Accept-Language -> the deployment default.

The recorded reasoning: a value the user chose is stronger evidence of intent than the Accept-Language the 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 sendOTP and sendPasswordResetOTP with { phoneNumber, code } and a ctx, so there is no request rung to rank on this surface.

What changed

  • AuthManager.storedRecipientLocale(where) — one projected findOne on sys_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 at sendResetPassword, sendVerificationEmail and sendChangeEmailNotice, the three sends that hold a recipient row.
  • AuthManager.renderPhoneSmsBody(topic, data, storedLocale) — the OTP send resolves the recipient by the unique phone_number and renders in their language.
  • Stale ladder docblocks on setDefaultSmsLocale, setDefaultEmailLocale, sendChangeEmailNotice and phone-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-US floor and the built-in en SMS 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 current origin/main, with a positive control first so a zero would be a live reading rather than a dead channel:

git grep -c "locale" origin/main -- packages/services/service-messaging/src/
-> 21 files (positive control: the channel is live)
git grep -c "normalizeRecipientLocale" origin/main -- packages/services/service-messaging/src/
-> recipient-locale.ts:5, recipient-locale.test.ts:12,
recipient-locale-shape-parity.test.ts:3, index.ts:1
git grep -n "normalizeRecipientLocale" origin/main -- .../src/index.ts
-> index.ts:68 (exported)

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-auth therefore declares @objectstack/service-messaging as a dependency. Precedent: @objectstack/plugin-webhooks already depends on it; no manifest cycle (check:workspace-manifest-cycles green).

The premise the card got wrong, and what replaced it

The card's suggested shape says to read the column "off the row sendPhoneOtp already 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-reset resolves the user for its own gate and does not pass it on (measured in the installed 1.7.x routes.mjs).

So the row is looked up here: one read on the unique phone_number index, 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 no sys_user row until acceptance. That is #14641's, and both abstentions are pinned rather than merely described.

sendMagicLink is 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 code only in the en row), and the three-rung mail pin gives each rung a different locale (stored ja-JP / header zh-CN / deployment es-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-manager by relative source path, so the mutated source is what vitest ran. Restore proved the same way: git diff HEAD empty, git status --porcelain empty, on-disk blob back to 3b6add63c4a4006266351d79fec6e9339ed40d5e, zero residual markers.

predictedmeasured
red55
auth-email-locale.test.tsoutranks-BOTH · catalog-language mapping · unshipped-tag passthroughsame 3, by name
auth-manager.test.tszh-CN user on en-US deployment · the reversesame 2, by name
green under ablationthe four floor pins and the two mechanism pins in each file311 passed

No 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.ts contract schema and no error-code ledger; it adds no exported symbol to any package's public API (both new helpers are private, and emailLocaleArg'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/spec is untouched. content/docs/releases/** is untouched. No test is skipped, disabled or quarantined.

Verification — at c2f8324 (final commit), tree clean

Every 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 stale dist. Exit 0.
  • pnpm --filter @objectstack/plugin-auth test93 files / 1958 tests passed, exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck — exit 0, all three programs. Its check:test-typecheck step is what makes this cover the new test files: 10 file(s) / 94 error(s) / 23 pinned signature(s) held, unmoved.
  • All 48 derived gate families (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.mjs answered exit 3 = NOT MEASURED, not a pass and not a red: it grades a saved turbo run test log and none exists locally. Unreachable in CI, which tees the log and passes the path. This diff cannot move it.
    • The ratchet families were re-run against the built workspace closure: check:type-check-debt --re-measure17 ledger entries re-measured, 217 raw tsc errors, none above its recorded number, surplus: none. check:dual-build-cjs-loads102 require entry points across 66 packages load.
    • Two gates were red first and are the reason the two config commits exist: check:test-source-alias and check:type-source-resolution both 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 — a declared narrowing, not a skip.eslint --no-inline-config --format json over 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 ONE eslint.config.mjs which never enables type-aware linting for any file — no parserOptions.project, no typed @typescript-eslint rules, 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, two tsconfig*.json, one changeset) fall outside every files glob 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

…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
@claudeclaudeBot changed the title fix(auth): read the recipient own sys_user.locale for auth OTP SMS and auth mailfix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mailSep 3, 2026
@github-actionsgithub-actionsBot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 7 documentable anchor(s). ⚠️5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 37056d579bdf4eebcd4d380efcfed1b867e5b6e1 — the merge of head 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 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

⚠️ 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 4428dd5756750939e2fd503e431c34c1cb83a19c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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
@os-sales
os-sales marked this pull request as ready for review September 3, 2026 23:26
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — green after patch round 1, undrafted then armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

State on head 19c2aed981: all checks complete, 0 failures, read at job level with the latest run taken per check name (Test Core is an aggregator over its shards and is not a substitute for reading them).

Landing order observed: undraft first, draft=false confirmed on read-back, then auto-merge armed at 23:26:09Z. Auto-merge does not survive a draft conversion, so the order is load-bearing. ⛔ One re-arm maximum, unspent.

No carrier is hung. Clause-② was declared no, and this seat verified the stated basis rather than accepting it: 0 added export lines across 574 added lines, with all three new helpers (storedRecipientLocale, emailLocaleArg, renderPhoneSmsBody) declared private.

What patch round 1 corrected, and what it deliberately did not

content/docs/permissions/authentication.mdx stated the OTP SMS locale "follows the deployment default", which this change makes false. The page now distinguishes the two SMS sends — the OTP takes the recipient's own sys_user.locale when the account has one (matched on phone_number, so an unmatched number takes the deployment default too), while the invitation SMS still reads the deployment default alone, since invitations are #14641's and untouched here.

⭐ The zh-CN → zh → en fallback chain was measured before being left alone, not assumed: phoneSmsLocaleChain is byte-identical to origin/main (sha256 9a009ec7c14f4a79 on both refs). It is a different mechanism from the rung ladder, so the corrected sentence says the chain resolves whichever locale the ladder named rather than restating it. The template-precedence rule, the hole lists and the best-effort sentence are untouched.

The docs sweep behind that correction gave every zero a positive control: localization.locale → exactly 1 hit (this page); setDefaultEmailLocale|setDefaultSmsLocale → 0 (control: three sibling greps non-zero on the same corpus); sys_user.locale → 2, both the messaging path #13881 already shipped correctly; Accept-Language → 8, all request-rendering or UI translation.

On MERGED, pm:dispatched comes off card #14762.


Generated by Claude Code

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

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail - #15107

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs
Sep 3, 2026
Merged

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail#15107
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14762

sys_user.locale has 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: 「同意」):

sys_user.locale when set -> the request's Accept-Language -> the deployment default.

The recorded reasoning: a value the user chose is stronger evidence of intent than the Accept-Language the 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 sendOTP and sendPasswordResetOTP with { phoneNumber, code } and a ctx, so there is no request rung to rank on this surface.

What changed

  • AuthManager.storedRecipientLocale(where) — one projected findOne on sys_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 at sendResetPassword, sendVerificationEmail and sendChangeEmailNotice, the three sends that hold a recipient row.
  • AuthManager.renderPhoneSmsBody(topic, data, storedLocale) — the OTP send resolves the recipient by the unique phone_number and renders in their language.
  • Stale ladder docblocks on setDefaultSmsLocale, setDefaultEmailLocale, sendChangeEmailNotice and phone-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-US floor and the built-in en SMS 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 current origin/main, with a positive control first so a zero would be a live reading rather than a dead channel:

git grep -c "locale" origin/main -- packages/services/service-messaging/src/
-> 21 files (positive control: the channel is live)
git grep -c "normalizeRecipientLocale" origin/main -- packages/services/service-messaging/src/
-> recipient-locale.ts:5, recipient-locale.test.ts:12,
recipient-locale-shape-parity.test.ts:3, index.ts:1
git grep -n "normalizeRecipientLocale" origin/main -- .../src/index.ts
-> index.ts:68 (exported)

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-auth therefore declares @objectstack/service-messaging as a dependency. Precedent: @objectstack/plugin-webhooks already depends on it; no manifest cycle (check:workspace-manifest-cycles green).

The premise the card got wrong, and what replaced it

The card's suggested shape says to read the column "off the row sendPhoneOtp already 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-reset resolves the user for its own gate and does not pass it on (measured in the installed 1.7.x routes.mjs).

So the row is looked up here: one read on the unique phone_number index, 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 no sys_user row until acceptance. That is #14641's, and both abstentions are pinned rather than merely described.

sendMagicLink is 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 code only in the en row), and the three-rung mail pin gives each rung a different locale (stored ja-JP / header zh-CN / deployment es-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-manager by relative source path, so the mutated source is what vitest ran. Restore proved the same way: git diff HEAD empty, git status --porcelain empty, on-disk blob back to 3b6add63c4a4006266351d79fec6e9339ed40d5e, zero residual markers.

predictedmeasured
red55
auth-email-locale.test.tsoutranks-BOTH · catalog-language mapping · unshipped-tag passthroughsame 3, by name
auth-manager.test.tszh-CN user on en-US deployment · the reversesame 2, by name
green under ablationthe four floor pins and the two mechanism pins in each file311 passed

No 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.ts contract schema and no error-code ledger; it adds no exported symbol to any package's public API (both new helpers are private, and emailLocaleArg'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/spec is untouched. content/docs/releases/** is untouched. No test is skipped, disabled or quarantined.

Verification — at c2f8324 (final commit), tree clean

Every 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 stale dist. Exit 0.
  • pnpm --filter @objectstack/plugin-auth test93 files / 1958 tests passed, exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck — exit 0, all three programs. Its check:test-typecheck step is what makes this cover the new test files: 10 file(s) / 94 error(s) / 23 pinned signature(s) held, unmoved.
  • All 48 derived gate families (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.mjs answered exit 3 = NOT MEASURED, not a pass and not a red: it grades a saved turbo run test log and none exists locally. Unreachable in CI, which tees the log and passes the path. This diff cannot move it.
    • The ratchet families were re-run against the built workspace closure: check:type-check-debt --re-measure17 ledger entries re-measured, 217 raw tsc errors, none above its recorded number, surplus: none. check:dual-build-cjs-loads102 require entry points across 66 packages load.
    • Two gates were red first and are the reason the two config commits exist: check:test-source-alias and check:type-source-resolution both 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 — a declared narrowing, not a skip.eslint --no-inline-config --format json over 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 ONE eslint.config.mjs which never enables type-aware linting for any file — no parserOptions.project, no typed @typescript-eslint rules, 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, two tsconfig*.json, one changeset) fall outside every files glob 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

…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
@claudeclaudeBot changed the title fix(auth): read the recipient own sys_user.locale for auth OTP SMS and auth mailfix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mailSep 3, 2026
@github-actionsgithub-actionsBot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 7 documentable anchor(s). ⚠️5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 37056d579bdf4eebcd4d380efcfed1b867e5b6e1 — the merge of head 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 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

⚠️ 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 4428dd5756750939e2fd503e431c34c1cb83a19c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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
@os-sales
os-sales marked this pull request as ready for review September 3, 2026 23:26
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — green after patch round 1, undrafted then armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

State on head 19c2aed981: all checks complete, 0 failures, read at job level with the latest run taken per check name (Test Core is an aggregator over its shards and is not a substitute for reading them).

Landing order observed: undraft first, draft=false confirmed on read-back, then auto-merge armed at 23:26:09Z. Auto-merge does not survive a draft conversion, so the order is load-bearing. ⛔ One re-arm maximum, unspent.

No carrier is hung. Clause-② was declared no, and this seat verified the stated basis rather than accepting it: 0 added export lines across 574 added lines, with all three new helpers (storedRecipientLocale, emailLocaleArg, renderPhoneSmsBody) declared private.

What patch round 1 corrected, and what it deliberately did not

content/docs/permissions/authentication.mdx stated the OTP SMS locale "follows the deployment default", which this change makes false. The page now distinguishes the two SMS sends — the OTP takes the recipient's own sys_user.locale when the account has one (matched on phone_number, so an unmatched number takes the deployment default too), while the invitation SMS still reads the deployment default alone, since invitations are #14641's and untouched here.

⭐ The zh-CN → zh → en fallback chain was measured before being left alone, not assumed: phoneSmsLocaleChain is byte-identical to origin/main (sha256 9a009ec7c14f4a79 on both refs). It is a different mechanism from the rung ladder, so the corrected sentence says the chain resolves whichever locale the ladder named rather than restating it. The template-precedence rule, the hole lists and the best-effort sentence are untouched.

The docs sweep behind that correction gave every zero a positive control: localization.locale → exactly 1 hit (this page); setDefaultEmailLocale|setDefaultSmsLocale → 0 (control: three sibling greps non-zero on the same corpus); sys_user.locale → 2, both the messaging path #13881 already shipped correctly; Accept-Language → 8, all request-rendering or UI translation.

On MERGED, pm:dispatched comes off card #14762.


Generated by Claude Code

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

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail - #15107

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs
Sep 3, 2026
Merged

fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail#15107
os-sales merged 3 commits into
mainfrom
claude/issue-14762-auth-locale-rungs

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14762

sys_user.locale has 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: 「同意」):

sys_user.locale when set -> the request's Accept-Language -> the deployment default.

The recorded reasoning: a value the user chose is stronger evidence of intent than the Accept-Language the 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 sendOTP and sendPasswordResetOTP with { phoneNumber, code } and a ctx, so there is no request rung to rank on this surface.

What changed

  • AuthManager.storedRecipientLocale(where) — one projected findOne on sys_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 at sendResetPassword, sendVerificationEmail and sendChangeEmailNotice, the three sends that hold a recipient row.
  • AuthManager.renderPhoneSmsBody(topic, data, storedLocale) — the OTP send resolves the recipient by the unique phone_number and renders in their language.
  • Stale ladder docblocks on setDefaultSmsLocale, setDefaultEmailLocale, sendChangeEmailNotice and phone-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-US floor and the built-in en SMS 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 current origin/main, with a positive control first so a zero would be a live reading rather than a dead channel:

git grep -c "locale" origin/main -- packages/services/service-messaging/src/
-> 21 files (positive control: the channel is live)
git grep -c "normalizeRecipientLocale" origin/main -- packages/services/service-messaging/src/
-> recipient-locale.ts:5, recipient-locale.test.ts:12,
recipient-locale-shape-parity.test.ts:3, index.ts:1
git grep -n "normalizeRecipientLocale" origin/main -- .../src/index.ts
-> index.ts:68 (exported)

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-auth therefore declares @objectstack/service-messaging as a dependency. Precedent: @objectstack/plugin-webhooks already depends on it; no manifest cycle (check:workspace-manifest-cycles green).

The premise the card got wrong, and what replaced it

The card's suggested shape says to read the column "off the row sendPhoneOtp already 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-reset resolves the user for its own gate and does not pass it on (measured in the installed 1.7.x routes.mjs).

So the row is looked up here: one read on the unique phone_number index, 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 no sys_user row until acceptance. That is #14641's, and both abstentions are pinned rather than merely described.

sendMagicLink is 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 code only in the en row), and the three-rung mail pin gives each rung a different locale (stored ja-JP / header zh-CN / deployment es-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-manager by relative source path, so the mutated source is what vitest ran. Restore proved the same way: git diff HEAD empty, git status --porcelain empty, on-disk blob back to 3b6add63c4a4006266351d79fec6e9339ed40d5e, zero residual markers.

predictedmeasured
red55
auth-email-locale.test.tsoutranks-BOTH · catalog-language mapping · unshipped-tag passthroughsame 3, by name
auth-manager.test.tszh-CN user on en-US deployment · the reversesame 2, by name
green under ablationthe four floor pins and the two mechanism pins in each file311 passed

No 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.ts contract schema and no error-code ledger; it adds no exported symbol to any package's public API (both new helpers are private, and emailLocaleArg'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/spec is untouched. content/docs/releases/** is untouched. No test is skipped, disabled or quarantined.

Verification — at c2f8324 (final commit), tree clean

Every 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 stale dist. Exit 0.
  • pnpm --filter @objectstack/plugin-auth test93 files / 1958 tests passed, exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck — exit 0, all three programs. Its check:test-typecheck step is what makes this cover the new test files: 10 file(s) / 94 error(s) / 23 pinned signature(s) held, unmoved.
  • All 48 derived gate families (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.mjs answered exit 3 = NOT MEASURED, not a pass and not a red: it grades a saved turbo run test log and none exists locally. Unreachable in CI, which tees the log and passes the path. This diff cannot move it.
    • The ratchet families were re-run against the built workspace closure: check:type-check-debt --re-measure17 ledger entries re-measured, 217 raw tsc errors, none above its recorded number, surplus: none. check:dual-build-cjs-loads102 require entry points across 66 packages load.
    • Two gates were red first and are the reason the two config commits exist: check:test-source-alias and check:type-source-resolution both 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 — a declared narrowing, not a skip.eslint --no-inline-config --format json over 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 ONE eslint.config.mjs which never enables type-aware linting for any file — no parserOptions.project, no typed @typescript-eslint rules, 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, two tsconfig*.json, one changeset) fall outside every files glob 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

…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
@claudeclaudeBot changed the title fix(auth): read the recipient own sys_user.locale for auth OTP SMS and auth mailfix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mailSep 3, 2026
@github-actionsgithub-actionsBot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 7 documentable anchor(s). ⚠️5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 5 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/tsconfig.examples.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 37056d579bdf4eebcd4d380efcfed1b867e5b6e1 — the merge of head 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 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

⚠️ 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 4428dd5756750939e2fd503e431c34c1cb83a19c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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
@os-sales
os-sales marked this pull request as ready for review September 3, 2026 23:26
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — green after patch round 1, undrafted then armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

State on head 19c2aed981: all checks complete, 0 failures, read at job level with the latest run taken per check name (Test Core is an aggregator over its shards and is not a substitute for reading them).

Landing order observed: undraft first, draft=false confirmed on read-back, then auto-merge armed at 23:26:09Z. Auto-merge does not survive a draft conversion, so the order is load-bearing. ⛔ One re-arm maximum, unspent.

No carrier is hung. Clause-② was declared no, and this seat verified the stated basis rather than accepting it: 0 added export lines across 574 added lines, with all three new helpers (storedRecipientLocale, emailLocaleArg, renderPhoneSmsBody) declared private.

What patch round 1 corrected, and what it deliberately did not

content/docs/permissions/authentication.mdx stated the OTP SMS locale "follows the deployment default", which this change makes false. The page now distinguishes the two SMS sends — the OTP takes the recipient's own sys_user.locale when the account has one (matched on phone_number, so an unmatched number takes the deployment default too), while the invitation SMS still reads the deployment default alone, since invitations are #14641's and untouched here.

⭐ The zh-CN → zh → en fallback chain was measured before being left alone, not assumed: phoneSmsLocaleChain is byte-identical to origin/main (sha256 9a009ec7c14f4a79 on both refs). It is a different mechanism from the rung ladder, so the corrected sentence says the chain resolves whichever locale the ladder named rather than restating it. The template-precedence rule, the hole lists and the best-effort sentence are untouched.

The docs sweep behind that correction gave every zero a positive control: localization.locale → exactly 1 hit (this page); setDefaultEmailLocale|setDefaultSmsLocale → 0 (control: three sibling greps non-zero on the same corpus); sys_user.locale → 2, both the messaging path #13881 already shipped correctly; Accept-Language → 8, all request-rendering or UI translation.

On MERGED, pm:dispatched comes off card #14762.


Generated by Claude Code

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

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude