From 6ce47549b260d4756002f2218531f92e08a4aa67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 22:11:53 +0000 Subject: [PATCH 1/3] fix(auth): read the recipient's own sys_user.locale for auth OTP SMS 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 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .../auth-stored-recipient-locale-rungs.md | 41 ++++ packages/plugins/plugin-auth/package.json | 1 + .../plugin-auth/src/auth-email-locale.test.ts | 174 +++++++++++++++- .../plugin-auth/src/auth-manager.test.ts | 133 ++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 191 +++++++++++++++--- .../plugin-auth/src/phone-sms-texts.ts | 19 +- pnpm-lock.yaml | 3 + 7 files changed, 522 insertions(+), 40 deletions(-) create mode 100644 .changeset/auth-stored-recipient-locale-rungs.md diff --git a/.changeset/auth-stored-recipient-locale-rungs.md b/.changeset/auth-stored-recipient-locale-rungs.md new file mode 100644 index 0000000000..f3b0eb91ab --- /dev/null +++ b/.changeset/auth-stored-recipient-locale-rungs.md @@ -0,0 +1,41 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(auth): auth OTP texts and auth mail now read the recipient's own `sys_user.locale` + +`sys_user.locale` has been a first-class column since #13881, and user-writable +since the 2026-09-03 ruling, but plugin-auth's own sends never read it: a phone +OTP was rendered in the deployment's language and auth mail in whichever +language the triggering *request* asked for. So a Chinese-speaking user on an +English deployment got an English verification code, and a password reset an +admin initiated for them carried the **admin's** browser language. + +Both ladders now start one rung higher, in the order ruled for #14788 +(option D, 2026-09-03): the recipient's own stored `sys_user.locale` → the +request's `Accept-Language` → the deployment default. The recorded reasoning is +that a value the user chose is stronger evidence of intent than the +`Accept-Language` the browser just sent — and the send that forces the order is +the one where the requester is not the recipient. + +- **Phone OTP SMS** (`/phone-number/send-otp` and + `/phone-number/request-password-reset`) resolves the recipient by the unique + `phone_number` and renders in their language. There is no request rung on this + surface — better-auth hands these callbacks a phone number and a code and + nothing else — so the chain is stored → deployment here. +- **Auth mail** — password reset, email verification and the change-email + notice — reads the column off the recipient row it already identifies. + +Everything underneath is unchanged. An account with no stored locale still gets +the deployment default, and with nothing configured at all the documented +`en-US` floor (and the built-in `en` SMS row) still applies. The read is +best-effort and never blocks a send: a missing column, an unavailable datasource +or a value that cannot name a language all resolve to "no stored preference", +never to a failed delivery. 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. + +Invitations are deliberately not included: an invitee has no `sys_user` row +until they accept, so both the mail and SMS invite paths keep the deployment +rung. diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index ccf440f822..2b7271bad1 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -34,6 +34,7 @@ "@objectstack/core": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/rest": "workspace:*", + "@objectstack/service-messaging": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "better-auth": "^1.7.2", diff --git a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts index 52611b868f..c769013606 100644 --- a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts +++ b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts @@ -8,11 +8,14 @@ * caller's own `Accept-Language` first (only when it names a locale in * `AUTH_EMAIL_TEMPLATE_LOCALES`), and the deployment default second. The * 2026-08-13 ruling had made the deployment default the whole answer and - * rejected `Accept-Language` outright. `sys_user.locale` exists since #13881 - * (2026-09-01) but auth mail does not read it yet — that is #14641's rung, - * and this file asserts nothing about it. The ruling text of record lives on - * `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the - * request rung's own cases are the last describe block in this file. + * rejected `Accept-Language` outright. #14762 then added the rung ABOVE both, + * per the #14788 option-D ruling of 2026-09-03: the recipient's own + * `sys_user.locale` (#13881) when the account holds one. Invitations keep the + * deployment rung — an invitee has no row until acceptance (#14641) — and + * this file pins that abstention too. The ruling text of record lives on + * `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` / + * `emailLocaleArg`; the request rung's own cases and the stored rung's are the + * last two describe blocks in this file. * * Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`, * so `EmailService`'s ladder always resolved `en-US` and the localized rows @@ -456,3 +459,164 @@ describe('#14319 — authEmailLocaleFromRequest', () => { expect(authEmailLocaleFromRequest(hostile)).toBeUndefined(); }); }); + +// ── #14762 — the stored rung ─────────────────────────────────────────────── + +/** + * #14788 was ruled option D on 2026-09-03 (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. The case that + * forces the order is the send where the requester is NOT the recipient — an + * admin-initiated password reset (`admin-import-users.ts` calls + * `requestPasswordReset`), where the request rung would otherwise stamp the + * ADMIN's browser language onto the USER's mail. + * + * ⚠️ Every rung below is given a DIFFERENT locale, so each assertion names + * exactly one rung. A pin that set two rungs to the same tag would pass + * whichever produced the value. + */ +async function driveResetWith(opts: { + stored?: unknown; + header?: string; + deployment?: string; + engine?: unknown; +}) { + const reads: any[] = []; + const dataEngine = + opts.engine ?? + { + async findOne(object: string, query: any) { + reads.push({ object, query }); + return object === 'sys_user' ? { locale: opts.stored } : null; + }, + }; + const { capturedConfig, sent } = await boot(opts.deployment, { dataEngine } as never); + const request = + opts.header === undefined + ? undefined + : new Request('http://x/any', { headers: { 'accept-language': opts.header } }); + await capturedConfig.emailAndPassword.sendResetPassword( + { user: USER, url: 'http://x/reset', token: 't' }, + request, + ); + return { sent, reads }; +} + +describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', () => { + const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED; + beforeEach(() => { + vi.clearAllMocks(); + process.env.OS_MCP_SERVER_ENABLED = 'false'; + }); + afterEach(() => { + if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED; + else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv; + }); + + it('the stored column outranks BOTH the request header and the deployment default', async () => { + // Three rungs, three distinct locales — the pin the card names. + const { sent } = await driveResetWith({ + stored: 'ja-JP', + header: 'zh-CN', + deployment: 'es-ES', + }); + expect(sent).toHaveLength(1); + expect(sent[0].locale).toBe('ja-JP'); + }); + + it('with no stored column the request rung answers — #14319 intact', async () => { + const { sent } = await driveResetWith({ stored: null, header: 'zh-CN', deployment: 'es-ES' }); + expect(sent[0].locale).toBe('zh-CN'); + }); + + it('with neither stored nor request, the deployment rung answers — #8195 intact', async () => { + const { sent } = await driveResetWith({ stored: null, deployment: 'es-ES' }); + expect(sent[0].locale).toBe('es-ES'); + }); + + it('with nothing at all, NO locale is named and the documented en-US floor applies', async () => { + const { sent } = await driveResetWith({ stored: null }); + expect(sent[0].locale).toBeUndefined(); + // The ladder's contract is written against an ABSENT key, not an explicit + // `undefined` — see the #8195 case above. + expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false); + }); + + it('reads the column off the recipient row by id, projected, under a system context', async () => { + // Establishes which rung produced the value above. + const { reads } = await driveResetWith({ stored: 'ja-JP', header: 'zh-CN' }); + const userRead = reads.find((r) => r.object === 'sys_user'); + expect(userRead, 'no sys_user read happened').toBeTruthy(); + expect(userRead.query.where).toEqual({ id: 'u1' }); + expect(userRead.query.fields).toEqual(['locale']); + expect(userRead.query.context?.isSystem).toBe(true); + }); + + it('maps a stored catalog language onto the row spelling', async () => { + // `zh` is a legal BCP-47 tag and a legal value of the column; auth rows + // are keyed `zh-CN` and matched exactly. + const { sent } = await driveResetWith({ stored: 'zh', deployment: 'es-ES' }); + expect(sent[0].locale).toBe('zh-CN'); + }); + + it('passes a stored tag we ship no row for through, unlike the request rung', async () => { + // The asymmetry is deliberate: the request rung REQUIRES a hit in + // AUTH_EMAIL_TEMPLATE_LOCALES because "a per-request header is a weaker + // claim than a deployment's declaration". A column the user set for + // themselves is not that weak claim — a tenant overlaying en-GB rows must + // be able to ask for them. + const { sent } = await driveResetWith({ stored: 'en-GB', header: 'zh-CN', deployment: 'es-ES' }); + expect(sent[0].locale).toBe('en-GB'); + // Same tag through the request rung is refused, unchanged. + expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en-GB' } })).toBeUndefined(); + }); + + it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => { + // hotcrm's measured dead-letter shape. `normalizeRecipientLocale` — the + // messaging seam's normalizer, reused rather than re-written — refuses it. + for (const junk of ['undefined', 'null', '', ' ', 42, {}]) { + const { sent } = await driveResetWith({ stored: junk, deployment: 'es-ES' }); + expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('es-ES'); + } + }); + + it('a failing recipient read never blocks the mail', async () => { + const { sent } = await driveResetWith({ + engine: { async findOne() { throw new Error('sys_user unavailable'); } }, + header: 'zh-CN', + deployment: 'es-ES', + }); + expect(sent).toHaveLength(1); + expect(sent[0].locale).toBe('zh-CN'); + }); + + it('an auth manager with no data engine keeps exactly the two-rung behaviour', async () => { + const { capturedConfig, sent } = await boot('es-ES'); + await capturedConfig.emailAndPassword.sendResetPassword( + { user: USER, url: 'http://x/reset', token: 't' }, + new Request('http://x/any', { headers: { 'accept-language': 'zh-CN' } }), + ); + expect(sent[0].locale).toBe('zh-CN'); + }); + + it('the INVITATION send is untouched — its rung is #14641\'s', async () => { + // Scope fence, asserted rather than described: an invitee has no sys_user + // row until acceptance, so this send still names the deployment rung even + // when a row for that address would have carried a locale. + const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } }; + const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never); + const org = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await org._opts.sendInvitationEmail({ + email: 'invitee@example.com', + invitation: { id: 'inv1', organizationId: 'o1', role: 'member' }, + organization: { name: 'Northwind' }, + inviter: { user: { email: 'dana@example.com', name: 'Dana' } }, + }); + expect(sent[0].template).toBe('auth.invitation'); + expect(sent[0].locale).toBe('es-ES'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 0be366f4e5..e38725279e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -2513,6 +2513,139 @@ describe('AuthManager', () => { expect(sms.sent[0].body).toContain('111222'); expect(sms.sent[0].body).toContain('verification code'); }); + + // ── #14762 — the recipient's own `sys_user.locale` as the top rung ────── + // + // #14788 was ruled option D on 2026-09-03: `sys_user.locale` when set → + // the request's `Accept-Language` → the deployment default. There is no + // request rung on this surface (better-auth's send-OTP callbacks receive + // `{ phoneNumber, code }` and nothing else), so the ruled chain collapses + // to stored → deployment here. + // + // ⚠️ Every case below asserts a body that DIFFERS between the two locales + // in both directions. `验证码` appears only in the zh row and + // `verification code` only in the en row, so a pin cannot pass on a + // template that reads the same either way. + describe('#14762 — the recipient locale outranks the deployment default', () => { + /** A data engine that answers the `sys_user` locale read and nothing else. */ + const engineWithUserLocale = (locale: unknown, calls?: any[]) => ({ + async find() { return []; }, // no tenant template row + async findOne(object: string, query: any) { + calls?.push({ object, query }); + if (object !== 'sys_user') return null; + return { locale }; + }, + }); + + it('renders a zh-CN user in Chinese even on an en-US deployment', async () => { + const { manager, opts } = await bootOtp({ dataEngine: engineWithUserLocale('zh-CN') }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + manager.setDefaultSmsLocale('en-US'); + + await opts.sendOTP({ phoneNumber: PHONE, code: '246810' }); + expect(sms.sent[0].body).toContain('验证码'); + expect(sms.sent[0].body).toContain('246810'); + // The direction that makes the pin real: the deployment's own row is + // NOT what went out. + expect(sms.sent[0].body).not.toContain('verification code'); + }); + + it('and the reverse: an en-US user on a zh-CN deployment gets English', async () => { + const { manager, opts } = await bootOtp({ dataEngine: engineWithUserLocale('en-US') }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + manager.setDefaultSmsLocale('zh-CN'); + + await opts.sendOTP({ phoneNumber: PHONE, code: '135791' }); + expect(sms.sent[0].body).toContain('verification code'); + expect(sms.sent[0].body).not.toContain('验证码'); + }); + + it('reads the column off the recipient row, projected, by phone number', async () => { + // Establishes WHICH rung produced the value above: a pin that only + // looked at the rendered body could be satisfied by any resolution + // step that happened to reach the same locale. + const calls: any[] = []; + const { manager, opts } = await bootOtp({ + dataEngine: engineWithUserLocale('zh-CN', calls), + }); + manager.setSmsService(fakeSms().service); + manager.setDefaultSmsLocale('en-US'); + + await opts.sendOTP({ phoneNumber: PHONE, code: '111111' }); + const userRead = calls.find((c) => c.object === 'sys_user'); + expect(userRead, 'no sys_user read happened').toBeTruthy(); + expect(userRead.query.where).toEqual({ phone_number: PHONE }); + expect(userRead.query.fields).toEqual(['locale']); + expect(userRead.query.context?.isSystem).toBe(true); + }); + + it('a user with NO stored locale still falls to the deployment default', async () => { + // The floor the ruling preserves: an unset column is not a choice. + const { manager, opts } = await bootOtp({ dataEngine: engineWithUserLocale(null) }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + manager.setDefaultSmsLocale('zh-CN'); + + await opts.sendOTP({ phoneNumber: PHONE, code: '222222' }); + expect(sms.sent[0].body).toContain('验证码'); + }); + + it('and with neither rung answering, the built-in en floor still holds', async () => { + const { manager, opts } = await bootOtp({ dataEngine: engineWithUserLocale(undefined) }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + // No setDefaultSmsLocale at all. + + await opts.sendOTP({ phoneNumber: PHONE, code: '333333' }); + expect(sms.sent[0].body).toContain('verification code'); + expect(sms.sent[0].body).toContain('333333'); + }); + + it('refuses the stringified-nothing literal rather than naming it a locale', async () => { + // The shape hotcrm measured dead-lettering every delivery. Reusing + // `normalizeRecipientLocale` is what refuses it — a second normalizer + // here would be a second place for that refusal to rot. + const { manager, opts } = await bootOtp({ dataEngine: engineWithUserLocale('undefined') }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + manager.setDefaultSmsLocale('zh-CN'); + + await opts.sendOTP({ phoneNumber: PHONE, code: '444444' }); + expect(sms.sent[0].body).toContain('验证码'); + }); + + it('a failing recipient read never blocks the OTP', async () => { + const { manager, opts } = await bootOtp({ + dataEngine: { + async find() { return []; }, + async findOne() { throw new Error('sys_user unavailable'); }, + }, + }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + manager.setDefaultSmsLocale('zh-CN'); + + await opts.sendOTP({ phoneNumber: PHONE, code: '555555' }); + expect(sms.sent[0].body).toContain('验证码'); + expect(sms.sent[0].body).toContain('555555'); + }); + + it('the SMS INVITE path is untouched — its rung is #14641\'s', async () => { + // Scope fence, asserted rather than described: an invitee's own column + // is another card's, so this send still names the deployment default + // even when the number resolves to a row with a locale. + const { manager } = await bootOtp({ dataEngine: engineWithUserLocale('zh-CN') }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + manager.setDefaultSmsLocale('en-US'); + + await manager.sendPhoneInviteSms(PHONE); + expect(sms.sent[0].body).toContain('Sign in with this phone number'); + expect(sms.sent[0].body).not.toContain('账号已开通'); + }); + }); }); // #2766 V1.5 — placeholder addresses must never become real recipients. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index e043239977..3b6add63c4 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -117,6 +117,17 @@ import { interpolatePhoneSms, loadPhoneSmsTemplateBody, } from './phone-sms-texts.js'; +// #14762 — the stored rung of the ruled locale ladder reuses the messaging +// seam's normalizer rather than growing a second one. `normalizeRecipientLocale` +// is the platform's ONE reader of a value at rest in `sys_user.locale`, and its +// refusal of the stringified-nothing literals (`"undefined"`, `"null"`) is part +// of its contract — hotcrm measured that literal dead-lettering every delivery +// for every user without a preference row. A private copy here would be a +// second place for that refusal to rot. +import { + RECIPIENT_LOCALE_FIELD, + normalizeRecipientLocale, +} from '@objectstack/service-messaging'; import { AUTH_USER_CONFIG, AUTH_SESSION_CONFIG, @@ -1461,10 +1472,17 @@ export class AuthManager { // background-task handling (see sendVerificationEmail) and the // forget-password route always returns {status:true}, so this never // leaks whether an address exists nor turns the request into a 500. + // #14762 — the ladder's stored rung. It matters most HERE: an + // admin-initiated reset (`admin-import-users.ts` calls + // `requestPasswordReset`) reaches this callback with the ADMIN's + // request, so without this rung the user's mail carries the admin's + // browser language. Best-effort; `undefined` leaves the #14319 + // ladder exactly as it was. + const storedLocale = await this.storedRecipientLocale({ id: user.id }); const result = await email.sendTemplate({ template: 'auth.password_reset', to: { address: user.email, ...(user.name ? { name: user.name } : {}) }, - ...this.emailLocaleArg(request), + ...this.emailLocaleArg(request, storedLocale), data: { user: { name: user.name || user.email, email: user.email, id: user.id }, resetUrl: url, @@ -1519,10 +1537,14 @@ export class AuthManager { // template/loader errors, and returns status:'failed' on transport // errors — surface both so resend is honest and signup stays // resilient via better-auth's background-task error handling. + // #14762 — the stored rung, same ladder as the reset above. An + // admin re-triggering verification for a user is the same + // requester-is-not-the-recipient shape. + const storedLocale = await this.storedRecipientLocale({ id: user.id }); const result = await email.sendTemplate({ template: 'auth.verify_email', to: { address: user.email, ...(user.name ? { name: user.name } : {}) }, - ...this.emailLocaleArg(request), + ...this.emailLocaleArg(request, storedLocale), data: { user: { name: user.name || user.email, email: user.email, id: user.id }, verificationUrl: url, @@ -4546,10 +4568,13 @@ export class AuthManager { * entirely and `EmailService`'s ladder resolves its documented `en-US` * default exactly as before. * - * Still NOT a per-recipient stored preference: `sys_user.locale` exists - * since #13881 (ruling 2026-09-01) but auth mail does not read it yet - * (#14762 for this send; #14641 for invitations). What is read is the - * language this request expressed, not a profile. + * #14762 layered the per-recipient stored preference on TOP of both rungs: + * `sys_user.locale` (#13881, ruling 2026-09-01) when the account holds one, + * then this request's `Accept-Language`, then the deployment default. The + * request rung did not lose its argument — it is still what answers for an + * account with no stored column — it lost the tie, per the #14788 option-D + * ruling of 2026-09-03. (Invitations remain #14641's: an invitee has no + * `sys_user` row to read.) */ private async sendChangeEmailNotice( from: { email: string; name?: string; id?: string }, @@ -4565,10 +4590,14 @@ export class AuthManager { // address that would actually land. const target = newEmail.trim().toLowerCase(); if (!target) return; + // #14762 — the stored rung on top of the #14319 ladder. The recipient is + // the account holder, so their own column outranks the header the + // request happened to carry. + const storedLocale = from.id ? await this.storedRecipientLocale({ id: from.id }) : undefined; await email.sendTemplate({ template: 'auth.email_change_notice', to: { address: from.email, ...(from.name ? { name: from.name } : {}) }, - ...this.emailLocaleArg(requestSource), + ...this.emailLocaleArg(requestSource, storedLocale), data: { user: { name: from.name || from.email, email: from.email, ...(from.id ? { id: from.id } : {}) }, newEmail: target, @@ -4677,15 +4706,37 @@ export class AuthManager { const otpCfg = this.config.phoneOtp ?? {}; const minutes = Math.max(1, Math.round((otpCfg.expiresIn ?? 300) / 60)); // #2815 — localised, tenant-customisable body: a sys_notification_template - // row for (auth.phone_otp, sms, deployment locale) wins; the built-in + // row for (auth.phone_otp, sms, resolved locale) wins; the built-in // bilingual text is the fallback. Purpose-neutral wording on purpose — // one provider template covers sign-in and reset, and the SMS reveals // nothing about what the code unlocks. - const body = await this.renderPhoneSmsBody(PHONE_SMS_TOPICS.otp, { - code, - appName: this.getAppName(), - minutes, - }); + // + // #14762 — the recipient of an OTP IS the user, so the locale is theirs to + // name: `sys_user.locale` first, the deployment default underneath. + // + // ⚠️ The row is looked up here rather than taken from the callback: the + // card's suggested shape assumed `sendPhoneOtp` already held one, and it + // does not — better-auth's phone-number plugin calls both `sendOTP` and + // `sendPasswordResetOTP` with `{ phoneNumber, code }` and no user + // (measured in the installed 1.7.x `routes.mjs`; `request-password-reset` + // resolves the user for its own gate and does not pass it on). One + // projected read on the unique `phone_number` index is the cost, paid only + // once the send is going to happen, and swallowed on any failure. + // + // ⚠️ Matched EXACTLY, as better-auth matches it for its own user lookup on + // the same route — a caller that posts a differently formatted spelling of + // a stored number resolves no row and lands on the deployment default, + // which is the documented floor rather than a failure. + const storedLocale = await this.storedRecipientLocale({ phone_number: phone }); + const body = await this.renderPhoneSmsBody( + PHONE_SMS_TOPICS.otp, + { + code, + appName: this.getAppName(), + minutes, + }, + storedLocale, + ); const result = await sms.send({ to: phone, body, @@ -4756,9 +4807,11 @@ export class AuthManager { * `kernel:ready` and on every settings change (same pattern as * {@link setAppName}). Unset ⇒ the built-in English text. * - * Per-user locale is not resolved here yet — `sys_user.locale` exists since - * #13881 (ruling 2026-09-01) and the messaging channels read it per - * recipient; auth SMS adopting it is #14762 (supersedes the #2815 note). + * #14762 — this is now the SECOND rung, not the whole answer. The OTP send + * reads the recipient's own `sys_user.locale` first (#13881, ruling + * 2026-09-01, the same column the messaging channels resolve per recipient) + * and falls here when the account holds none. The SMS invite path still + * names this rung alone — its recipient's column is #14641's. */ setDefaultSmsLocale(locale: string | undefined): void { this.smsLocale = locale?.trim() || undefined; @@ -4804,16 +4857,55 @@ export class AuthManager { * * Per-user locale EXISTS since #13881 (maintainer ruling 2026-09-01): * `sys_user.locale`, resolved per recipient by service-messaging for - * notification mail (`recipient-locale.ts`). Auth mail does NOT read it - * yet — this ladder stays request rung → deployment rung. Layering the - * user's own column on top as a third rung is #14641 (invitations) and - * its own card for the other sends; nothing here is wasted by that. + * notification mail (`recipient-locale.ts`). #14762 layered it on top of + * this ladder for the sends that hold a recipient row — reset, verification + * and the change-email notice — so the order is stored → request → this + * rung, per the #14788 option-D ruling of 2026-09-03. Nothing here changed: + * this is still what answers when neither rung above names a locale, which + * is every send to an account that never set one. The invitation send still + * reads this rung alone (#14641). */ setDefaultEmailLocale(locale: string | undefined): void { this.emailLocale = normalizeAuthEmailLocale(locale); } private emailLocale?: string; + /** + * #14762 — the ladder's TOP rung: the recipient's own `sys_user.locale`, + * read best-effort off the identity row. + * + * Returns `undefined` for every shape that cannot name a language — no data + * engine, no matching row, an unset column, or a value + * {@link normalizeRecipientLocale} refuses. ⛔ It must never throw and must + * never be the reason a send fails: a language is a courtesy, delivery is + * not, and every caller below layers the remaining rungs underneath. That is + * the same posture `service-messaging`'s email channel takes on the + * notification path (`email-channel.ts` retries address-only and warns), and + * the ruling's item 3 — no path may dead-letter because of the locale read. + * + * The read is one row on an indexed predicate (`sys_user.id`, or the unique + * `phone_number`), projected to the single column, under a system context — + * the recipient's own language must resolve regardless of who triggered the + * send, which is exactly the admin-initiated case this card is about. + */ + private async storedRecipientLocale(where: Record): Promise { + const engine = this.getDataEngine(); + if (!engine || typeof engine.findOne !== 'function') return undefined; + try { + const row = await engine.findOne('sys_user', { + where, + fields: [RECIPIENT_LOCALE_FIELD], + context: { isSystem: true, positions: [], permissions: [] }, + } as never); + return normalizeRecipientLocale((row as Record | null | undefined)?.[RECIPIENT_LOCALE_FIELD]); + } catch { + // A `sys_user` shape without the column, a datasource outage, an engine + // that rejects the projection — all mean "no stored language", never + // "no mail". + return undefined; + } + } + /** * The `locale` fragment spread into every auth `sendTemplate` call. * @@ -4821,12 +4913,33 @@ export class AuthManager { * and an absent key travel the same path today, but only the absent key is * what the ladder's "no locale means the DOCUMENTED default" contract is * written against. - */ - private emailLocaleArg(requestSource?: unknown): { locale?: string } { - // #14319 — request rung first, deployment rung underneath. Callers that - // have no request (or whose recipient is not the requester) pass nothing - // and get exactly the pre-#14319 behaviour. - const locale = authEmailLocaleFromRequest(requestSource) ?? this.emailLocale; + * + * #14762 — three rungs now, in the order ruled for #14788 on 2026-09-03 + * (option D): the recipient's own **stored** `sys_user.locale` → the + * **request**'s `Accept-Language` (#14319) → the **deployment** default + * (#8195). The recorded reasoning is that a value the user chose is stronger + * evidence of intent than the `Accept-Language` the browser just sent — and + * the case that forces the order is the send where the requester is NOT the + * recipient (an admin-initiated password reset), where the request rung + * carries the *admin's* browser language onto the *user's* mail. + * + * `storedLocale` arrives already through {@link normalizeRecipientLocale} + * (see {@link storedRecipientLocale}); {@link normalizeAuthEmailLocale} then + * maps it onto the row spelling, exactly as the deployment rung is mapped. + * Deliberately NOT the narrower `authEmailLocaleFromRequest` treatment, which + * requires a hit in `AUTH_EMAIL_TEMPLATE_LOCALES`: that narrowing exists + * because "a per-request header is a weaker claim than a deployment's + * declaration", and a column the user set for themselves is not the weak + * claim — a tenant overlaying `en-GB` rows must be able to ask for them. + * + * A caller with no recipient row in hand passes nothing and gets exactly the + * two-rung #14319 behaviour. + */ + private emailLocaleArg(requestSource?: unknown, storedLocale?: string): { locale?: string } { + const locale = + normalizeAuthEmailLocale(storedLocale) ?? + authEmailLocaleFromRequest(requestSource) ?? + this.emailLocale; return locale ? { locale } : {}; } @@ -4835,11 +4948,29 @@ export class AuthManager { * `sys_notification_template` row for `(topic, 'sms', locale chain)` when * one exists, else the built-in bilingual text. Template lookups are * best-effort — an outage must never block an OTP send. - */ - private async renderPhoneSmsBody(topic: string, data: Record): Promise { + * + * #14762 — `storedLocale` is the recipient's own `sys_user.locale` when the + * caller could resolve one ({@link storedRecipientLocale}); the deployment + * default stands underneath it. There is NO request rung on this surface: + * the ruled ladder's middle rung is the request's `Accept-Language`, and an + * SMS body is chosen at delivery time from a phone number — the send-OTP + * route hands the callback `{ phoneNumber, code }` and nothing else + * (measured against the installed better-auth 1.7.x, not assumed). So the + * ruled chain collapses here to stored → deployment, with the built-in `en` + * row as {@link phoneSmsLocaleChain}'s terminal floor exactly as before. + * + * A caller that passes nothing — the SMS invite path, whose rung is #14641's + * — gets exactly the pre-#14762 deployment-default behaviour. + */ + private async renderPhoneSmsBody( + topic: string, + data: Record, + storedLocale?: string, + ): Promise { + const locale = storedLocale ?? this.smsLocale; const template = - (await loadPhoneSmsTemplateBody(this.getDataEngine(), topic, this.smsLocale)) ?? - builtinPhoneSmsBody(topic, this.smsLocale); + (await loadPhoneSmsTemplateBody(this.getDataEngine(), topic, locale)) ?? + builtinPhoneSmsBody(topic, locale); return interpolatePhoneSms(template, data); } diff --git a/packages/plugins/plugin-auth/src/phone-sms-texts.ts b/packages/plugins/plugin-auth/src/phone-sms-texts.ts index 829fe81900..ea870a7e77 100644 --- a/packages/plugins/plugin-auth/src/phone-sms-texts.ts +++ b/packages/plugins/plugin-auth/src/phone-sms-texts.ts @@ -13,11 +13,20 @@ * 2. **Built-in fallback** — the bundled texts here (en + zh), used when * no template row resolves (fresh env, missing table, exotic locale). * - * The recipient locale is the DEPLOYMENT default (`localization.locale` - * setting). `sys_user.locale` exists since #13881 (ruling 2026-09-01) and - * the messaging channels read it per recipient; the auth OTP/invite texts - * here do not read it yet — adopting it is its own card, and until then the - * deployment default is the correct behaviour here, not an accident. + * The recipient locale reaching this module is resolved by the caller + * (`AuthManager.renderPhoneSmsBody`), and #14762 gave the OTP send a rung + * above the deployment default: the recipient's own `sys_user.locale` + * (#13881, ruling 2026-09-01 — the same column the messaging channels read + * per recipient), then the DEPLOYMENT default (`localization.locale` + * setting). There is no request rung on this surface: better-auth hands the + * send-OTP callbacks `{ phoneNumber, code }` and nothing else, so the ruled + * chain (#14788 option D, 2026-09-03) collapses to stored → deployment here. + * + * The SMS **invite** path still names the deployment default alone — its + * recipient's own column is #14641's rung, not this one's. + * + * Whatever arrives, {@link phoneSmsLocaleChain}'s terminal `en` remains the + * floor: this module never returns nothing, and an OTP never fails to render. * * Red line unchanged: the OTP code appears only in the rendered body handed * to the SMS service — never in logs or error messages. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd64d536b5..29dc9584df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1569,6 +1569,9 @@ importers: '@objectstack/rest': specifier: workspace:* version: link:../../rest + '@objectstack/service-messaging': + specifier: workspace:* + version: link:../../services/service-messaging '@objectstack/spec': specifier: workspace:* version: link:../../spec From c2f8324506eee43353d98f609633137c46a96090 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 22:21:26 +0000 Subject: [PATCH 2/3] build(auth): resolve @objectstack/service-messaging from source in tsc 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 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .../plugin-auth/tsconfig.examples.json | 12 ++++++--- packages/plugins/plugin-auth/tsconfig.json | 27 +++++++++++++++++-- packages/plugins/plugin-auth/vitest.config.ts | 9 +++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-auth/tsconfig.examples.json b/packages/plugins/plugin-auth/tsconfig.examples.json index 5f493fc408..721d94167c 100644 --- a/packages/plugins/plugin-auth/tsconfig.examples.json +++ b/packages/plugins/plugin-auth/tsconfig.examples.json @@ -42,10 +42,14 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, - // `.` rather than the inherited `src`, because the files this program - // checks (`examples/**/*`, `objectstack.config.ts`) sit outside `src`. - // Safe precisely because nothing is emitted from here -- see the header. - "rootDir": "." + // `../..` (= `packages/`) rather than the inherited value, because the + // files this program checks (`examples/**/*`, `objectstack.config.ts`) sit + // outside `src`, and #14762's `paths` rule on the parent config pulls + // `packages/services/service-messaging/src/**` into the program too -- + // `rootDir` is enforced over every program file even under `--noEmit`, so + // it has to be the directory containing both. Safe precisely because + // nothing is emitted from here -- see the header. + "rootDir": "../.." }, "include": ["examples/**/*", "objectstack.config.ts"] } diff --git a/packages/plugins/plugin-auth/tsconfig.json b/packages/plugins/plugin-auth/tsconfig.json index f6a1e8bad5..7d7b6ffe29 100644 --- a/packages/plugins/plugin-auth/tsconfig.json +++ b/packages/plugins/plugin-auth/tsconfig.json @@ -2,10 +2,33 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", + // [#14762] Widened from `./src` as a CONSEQUENCE of the `paths` rule below, + // the same consequence `packages/plugins/plugin-security/tsconfig.json` + // (#11184) and `packages/rest/tsconfig.json` (#9960) each record: + // redirecting a workspace specifier to its source puts that package's + // `src/**` into this program, and `rootDir` is enforced over every program + // file even under `--noEmit`. `../..` (= `packages/`) is the directory that + // contains every file in the program. Emit is unaffected: this package + // builds with tsup, and `typecheck` passes `--noEmit`. + "rootDir": "../..", "types": [ "node" - ] + ], + // [#14762] `@objectstack/service-messaging` is imported as a VALUE by + // `src/auth-manager.ts` (`normalizeRecipientLocale`, the platform's one + // reader of a `sys_user.locale` at rest — reused rather than copied). + // Without this rule tsc resolves the specifier through the dependency's + // `exports` map — `dist/index.d.ts`, a BUILD ARTIFACT — so this package's + // typecheck would render a verdict about the last `pnpm build` rather than + // about the producer's source in the checkout, which + // `check:type-source-resolution` refuses (its header states why the + // dangerous case is a typecheck that PASSES). ONE rule for the bare name + // only: this package imports no `@objectstack/service-messaging/*` subpath + // — that package declares none — and a `paths` target matching nothing on + // disk would silently fall back to node resolution. + "paths": { + "@objectstack/service-messaging": ["../../services/service-messaging/src/index.ts"] + } }, "include": [ "src/**/*" diff --git a/packages/plugins/plugin-auth/vitest.config.ts b/packages/plugins/plugin-auth/vitest.config.ts index ff8d75bd92..db390bdd67 100644 --- a/packages/plugins/plugin-auth/vitest.config.ts +++ b/packages/plugins/plugin-auth/vitest.config.ts @@ -28,6 +28,15 @@ export default defineConfig({ find: /^@objectstack\/plugin-security$/, replacement: path.resolve(here, '../plugin-security/src/index.ts'), }, + // [#14762] `auth-manager.ts` takes a VALUE import on + // `@objectstack/service-messaging` (`normalizeRecipientLocale`). Same + // reason as the entry above, and the same anchoring: a unit test is a + // verdict about the SOURCE in this checkout, so the specifier must not + // resolve to a `dist/` that may predate the edit under test. + { + find: /^@objectstack\/service-messaging$/, + replacement: path.resolve(here, '../../services/service-messaging/src/index.ts'), + }, ], }, }); From 19c2aed981d6a8c1ae2e12bfa082fd9319c753c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 23:05:47 +0000 Subject: [PATCH 3/3] docs(auth): the OTP SMS locale starts at the recipient's sys_user.locale 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 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- content/docs/permissions/authentication.mdx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index e03713a796..fa4fa71296 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -444,8 +444,12 @@ The OTP and invitation bodies are localised and tenant-customisable: a `sys_notification_template` row for `(auth.phone_otp | auth.phone_invite, channel 'sms', locale)` wins — built-in English and Chinese rows are seeded once (never overwriting your edits) and can be changed under Setup → -Notification Templates. The locale follows the deployment default -(`localization.locale` setting) with a `zh-CN → zh → en` fallback chain; +Notification Templates. For the **OTP** the locale is the recipient's own +`sys_user.locale` when their account has one, and the deployment default +(`localization.locale` setting) otherwise — the account is matched on its +`phone_number`, so a number no account carries takes the deployment default +too. The **invitation** SMS reads the deployment default alone. Whichever +locale that names is then resolved with a `zh-CN → zh → en` fallback chain; holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`, `{{loginUrl}}` (invitation — `{{baseUrl}}`, the bare origin, is still interpolated for tenant templates written against the older text). Template