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/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 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/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'), + }, ], }, }); 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