diff --git a/.changeset/invitation-invitee-stored-locale.md b/.changeset/invitation-invitee-stored-locale.md new file mode 100644 index 0000000000..574351c7e7 --- /dev/null +++ b/.changeset/invitation-invitee-stored-locale.md @@ -0,0 +1,64 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(auth): an invitation is written in the invitee's own `sys_user.locale` when the address already holds a row, and keeps the deployment default when it does not (#14641) + +The four auth sends whose requester IS the recipient gained a per-recipient +language rung in #14762 (`sys_user.locale`, ruled on #13881). The two +**invitation** sends did not, and the recorded reason was structural rather +than an oversight: an invitee generally has no `sys_user` row until they accept, +so there is no stored language to read, and the *inviter's* `Accept-Language` is +the wrong authority — an English-speaking admin would silently send English +invitations to a Chinese-language workspace's new hires. + +That reason covers only one of the two populations an invitation reaches. This +change gives both invitation sends the same top rung the other four already +read, on a **two-branch** shape: + +1. the address (or phone number) **already carries** a `sys_user` row whose + `locale` is set — an existing platform user invited into a second + organization, or a re-invitation — that row's `locale` wins; +2. a genuinely **new** invitee with **no** row keeps the deployment default, + because their language is still truly unknown at invitation time. So does an + invitee whose row exists but names no language: an unset column is not a + choice. + +⛔ The inviter direction stays rejected on both branches, and is now pinned +against a manager that has the top rung wired rather than against one with no +rung at all. #13881's ruling item 3 fixes the chain as **recipient** locale → +deployment default; what opened here is the invitee's own column, never the +inviter's header. + +**Both branches are reachable, measured rather than assumed.** +`sendInvitationEmail`: better-auth's `create-invitation` route rejects only an +address that is already a member of *this* organization +(`USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION`, `routes/crud-invites.mjs` in +the installed 1.7.2), so an existing account invited elsewhere — and the +`resend` branch — reach the callback normally. `sendPhoneInviteSms` reaches a row by +construction: its one in-repo caller, the identity import endpoint's `invite` +policy, **creates** the account and only then sends the SMS. + +⚠️ **What the SMS path yields today, stated precisely, because a changeset +becomes release notes.** The rung is wired there and reads the row whenever the +row carries a locale — but `admin-import-users.ts` never writes `locale` (0 +occurrences; positive control: `sendInviteSms` appears twice in the same file), +and `sys_user.locale` declares no column default. So on the only in-repo caller +the column is empty at send time and the invitation SMS still resolves to the +**deployment default** — the pre-change behaviour, unchanged for that flow. What +this buys on that surface is the rung itself: an out-of-repo caller, or a future +import that populates `locale`, is read rather than ignored. The behaviour users +see change today is on the invitation **email**. + +**Matching is exact, and that is safe rather than merely tolerable here.** +better-auth lowercases the invitee address on the invite route and the stored +`user.email` on sign-up, so both sides of the predicate are already in the same +case; `email` and `phone_number` are both `unique: true` in the `user` table +`sys_user` is backed by. An address that resolves no row lands on the deployment +default, which is the documented floor rather than a failure — and, as +everywhere else on this ladder, a failing recipient read never blocks a send. + +**Docs.** `permissions/authentication.mdx` said "The **invitation** SMS reads +the deployment default alone"; that sentence is now false and is corrected. No +shipped page states the invitation *email* locale rule (the auth email ladder is +undocumented as a whole), so nothing else moved. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index fa4fa71296..78ab7a7d91 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -444,11 +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. For the **OTP** the locale is the recipient's own +Notification Templates. Both bodies resolve the same way: 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 +too. For the **invitation** SMS the account normally does exist, because the +identity import endpoint creates it and only then sends the message. 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 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 c769013606..13d7d2cd53 100644 --- a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts +++ b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts @@ -10,12 +10,31 @@ * 2026-08-13 ruling had made the deployment default the whole answer and * 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. + * `sys_user.locale` (#13881) when the account holds one. + * + * #14641 reached the INVITATION send last, and it is the one send with two + * branches rather than one. The card's terminal state read "choose the + * template by the invitee's stored language", which cannot hold for every + * invitee — an invitee generally has no `sys_user` row until acceptance, so + * there is no stored language to read. What IS implementable, and what this + * file pins, is the two-branch shape: + * + * 1. the address ALREADY carries a `sys_user` row — an existing platform + * user invited into a second organization, or a re-invitation → their own + * `locale`; + * 2. a genuinely new invitee with NO row → the deployment default, because + * their language is still truly unknown at invitation time. + * + * ⛔ The INVITER direction stays rejected on both branches: #13881's ruling + * item 3 fixes the chain as RECIPIENT locale → deployment default, and + * stamping the inviter's `Accept-Language` onto the invitee's mail would move + * the defect one seat over. That abstention is pinned here too, now against a + * manager that HAS the top rung wired — the stronger form of the #14319 pin. + * + * The ruling text of record lives on `AuthManager.setDefaultEmailLocale` / + * `authEmailLocaleFromRequest` / `emailLocaleArg`; the request rung's own + * cases, the stored rung's, and the invitation's two branches are the last + * three 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 @@ -603,10 +622,10 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', ( 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. + it('the INVITATION send reads the SAME rung, on the address — #14641', async () => { + // Was a scope fence ("untouched — its rung is #14641's") until #14641 + // landed. The rung is the same one; only the predicate differs, because + // this callback is handed an address rather than a user row. 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'); @@ -617,6 +636,215 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', ( inviter: { user: { email: 'dana@example.com', name: 'Dana' } }, }); expect(sent[0].template).toBe('auth.invitation'); - expect(sent[0].locale).toBe('es-ES'); + expect(sent[0].locale).toBe('ja-JP'); + expect(sent[0].locale).not.toBe('es-ES'); + }); +}); + +// ── #14641 — the invitation send's two branches ──────────────────────────── + +/** + * A `sys_user` table keyed by ADDRESS, so the only thing separating the two + * branches is whether the invitee's address carries a row. One engine object + * is shared between drives wherever a test needs the branches to be provably + * the same lookup — otherwise "no row" and "no read" would be indistinguishable + * from the outside, since both land on the deployment default. + */ +function emailKeyedEngine(rows: Record) { + const reads: any[] = []; + return { + reads, + engine: { + async findOne(object: string, query: any) { + reads.push({ object, query }); + if (object !== 'sys_user') return null; + const email = (query?.where ?? {}).email as string; + return Object.prototype.hasOwnProperty.call(rows, email) + ? { locale: rows[email] } + : null; + }, + }, + }; +} + +async function driveInvitation(opts: { + engine: unknown; + deployment?: string; + invitee?: string; + /** The INVITER's browser language — better-auth hands this callback its request. */ + header?: string; +}) { + const { capturedConfig, sent } = await boot(opts.deployment, { + dataEngine: opts.engine, + } as never); + const org = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await org._opts.sendInvitationEmail( + { + email: opts.invitee ?? 'invitee@example.com', + invitation: { id: 'inv1', organizationId: 'o1', role: 'member' }, + organization: { name: 'Northwind' }, + inviter: { user: { email: 'dana@example.com', name: 'Dana' } }, + }, + opts.header === undefined + ? undefined + : new Request('http://x/invite', { headers: { 'accept-language': opts.header } }), + ); + return sent; +} + +describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () => { + 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('BRANCH 1 — an address that already has a row is written in THAT locale', async () => { + const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' }); + const sent = await driveInvitation({ engine, deployment: 'en-US' }); + expect(sent[0].template).toBe('auth.invitation'); + expect(sent[0].locale).toBe('zh-CN'); + // The direction that makes the pin real: the deployment's own tag is NOT + // what went out. + expect(sent[0].locale).not.toBe('en-US'); + }); + + it('and the reverse — an en-US invitee on a zh-CN deployment gets English', async () => { + // Swapping the two tags is what rules out a pin that would pass because + // one of them always wins. + const { engine } = emailKeyedEngine({ 'invitee@example.com': 'en-US' }); + const sent = await driveInvitation({ engine, deployment: 'zh-CN' }); + expect(sent[0].locale).toBe('en-US'); + expect(sent[0].locale).not.toBe('zh-CN'); + }); + + it('BRANCH 2 — a genuinely new invitee, no row, still takes the deployment default', async () => { + // ⚠️ Positive control for the zero, and the reason ONE engine drives both + // sends: the same table, the same predicate and the same deployment answer + // zh-CN for an address that carries a row and en-US for one that does not. + // That is what separates "the read ran and found nothing" from "the read + // never ran" / "this engine answers nothing" — both of which would also + // land on the deployment default and look identical from the payload. + const { engine, reads } = emailKeyedEngine({ 'known@example.com': 'zh-CN' }); + + const known = await driveInvitation({ engine, deployment: 'en-US', invitee: 'known@example.com' }); + expect(known[0].locale).toBe('zh-CN'); + + const newcomer = await driveInvitation({ engine, deployment: 'en-US', invitee: 'newcomer@example.com' }); + expect(newcomer[0].locale).toBe('en-US'); + expect(newcomer[0].locale).not.toBe('zh-CN'); + + // ...and the newcomer's read really was attempted, on their address. + const userReads = reads.filter((r) => r.object === 'sys_user'); + expect(userReads.map((r) => r.query.where)).toEqual([ + { email: 'known@example.com' }, + { email: 'newcomer@example.com' }, + ]); + }); + + it("reads the column off the INVITEE's address — never the inviter's", async () => { + // Establishes WHICH rung produced the value, and on WHOSE identity. The + // inviter has a row too, carrying a different language; it must not be + // reached at all. + const { engine, reads } = emailKeyedEngine({ + 'invitee@example.com': 'zh-CN', + 'dana@example.com': 'ja-JP', + }); + const sent = await driveInvitation({ engine, deployment: 'en-US' }); + expect(sent[0].locale).toBe('zh-CN'); + expect(sent[0].locale).not.toBe('ja-JP'); + + const userReads = reads.filter((r) => r.object === 'sys_user'); + expect(userReads).toHaveLength(1); + expect(userReads[0].query.where).toEqual({ email: 'invitee@example.com' }); + expect(userReads[0].query.fields).toEqual(['locale']); + expect(userReads[0].query.context?.isSystem).toBe(true); + }); + + it("⛔ the INVITER's Accept-Language still loses — with the top rung now wired", async () => { + // The #14319 abstention, re-pinned in its stronger form: this send reads a + // recipient rung now, so "no request argument" is no longer trivially true + // of the whole callback. An English-speaking admin must still not force + // English onto a Chinese workspace's new hire. + const { engine } = emailKeyedEngine({}); + const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' }); + expect(sent[0].locale).toBe('zh-CN'); + expect(sent[0].locale).not.toBe('en-US'); + }); + + it("...and does not win over the invitee's stored column either", async () => { + const { engine } = emailKeyedEngine({ 'invitee@example.com': 'ja-JP' }); + const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' }); + expect(sent[0].locale).toBe('ja-JP'); + expect(sent[0].locale).not.toBe('en-US'); + }); + + it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => { + for (const junk of ['undefined', 'null', '', ' ', 42, {}]) { + const { engine } = emailKeyedEngine({ 'invitee@example.com': junk }); + const sent = await driveInvitation({ engine, deployment: 'en-US' }); + expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('en-US'); + } + }); + + it('a failing recipient read never blocks the invitation', async () => { + const engine = { async findOne() { throw new Error('sys_user unavailable'); } }; + const sent = await driveInvitation({ engine, deployment: 'en-US' }); + expect(sent).toHaveLength(1); + expect(sent[0].template).toBe('auth.invitation'); + expect(sent[0].locale).toBe('en-US'); + }); + + it('with no data engine at all, the deployment rung answers exactly as before', async () => { + const { capturedConfig, sent } = await boot('en-US'); + 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].locale).toBe('en-US'); + }); + + it('with neither a row nor a deployment default, NO locale is named at all', async () => { + // The ladder's contract is written against an ABSENT key. + const { engine } = emailKeyedEngine({}); + const sent = await driveInvitation({ engine }); + expect(sent[0].locale).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false); + }); + + it('does not disturb the rest of the invitation payload', async () => { + const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' }); + const sent = await driveInvitation({ engine, deployment: 'en-US' }); + expect(sent[0].to).toBe('invitee@example.com'); + expect(sent[0].relatedObject).toBe('sys_invitation'); + expect(sent[0].relatedId).toBe('inv1'); + expect(sent[0].organizationId).toBe('o1'); + expect(sent[0].data.organization.name).toBe('Northwind'); + expect(sent[0].data.role).toBe('member'); + }); + + it('a placeholder address is still refused BEFORE any recipient read', async () => { + // #2766 V1.5 ordering, re-pinned now that a read sits on this path: the + // refusal must not be preceded by a lookup for an address that is not a + // real recipient. + const { engine, reads } = emailKeyedEngine({}); + const { capturedConfig } = await boot('en-US', { dataEngine: engine } as never); + const org = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await expect( + org._opts.sendInvitationEmail({ + email: 'u-abcdefghijklmnopqrst@placeholder.invalid', + invitation: { id: 'inv1', organizationId: 'o1', role: 'member' }, + organization: { name: 'Northwind' }, + inviter: { user: { email: 'dana@example.com', name: 'Dana' } }, + }), + ).rejects.toThrow(/placeholder address/); + expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0); }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index e38725279e..7decbd1f23 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -2631,20 +2631,128 @@ describe('AuthManager', () => { 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') }); + // ── #14641 — the SMS INVITE path gets the same rung ────────────────── + // + // Its OWN describe, sibling to #14762 above rather than nested inside it: + // the reporter path is what the next reader greps, and these pins answer + // for #14641, not for the card that gave the OTP send its rung. + describe("#14641 — the invitation SMS reads the invitee's own locale", () => { + // Was a scope fence ("untouched — its rung is #14641's") until #14641 + // landed. The existing-row branch is reachable by construction: the one + // in-repo caller (the identity import endpoint's `invite` policy) CREATES + // the account before sending. ⚠️ It does NOT populate `locale`, and the + // column has no default, so that caller still lands on the deployment + // rung today — these pins drive the engine directly, which is what lets + // them measure the rung the in-repo flow does not yet exercise. + // + // ⚠️ The two built-in invite bodies genuinely differ, so each assertion + // names one locale in BOTH directions: the marker for the locale that + // should have won is present, and the marker for the other is absent. + // A phone-keyed engine drives both branches so "no row" is provably a + // live lookup that found nothing. + const phoneKeyedEngine = (rows: Record, 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; + const phone = (query?.where ?? {}).phone_number as string; + return Object.prototype.hasOwnProperty.call(rows, phone) + ? { locale: rows[phone] } + : null; + }, + }); + const OTHER_PHONE = '+8613900000001'; + + it('BRANCH 1 — an invitee whose row says zh-CN is texted in Chinese', async () => { + const { manager } = await bootOtp({ + dataEngine: phoneKeyedEngine({ [PHONE]: 'zh-CN' }), + }); const sms = fakeSms(); manager.setSmsService(sms.service); manager.setDefaultSmsLocale('en-US'); + await manager.sendPhoneInviteSms(PHONE); + expect(sms.sent[0].body).toContain('账号已开通'); + expect(sms.sent[0].body).not.toContain('Sign in with this phone number'); + }); + + it('and the reverse — an en-US row on a zh-CN deployment is texted in English', async () => { + const { manager } = await bootOtp({ + dataEngine: phoneKeyedEngine({ [PHONE]: 'en-US' }), + }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + manager.setDefaultSmsLocale('zh-CN'); + await manager.sendPhoneInviteSms(PHONE); expect(sms.sent[0].body).toContain('Sign in with this phone number'); expect(sms.sent[0].body).not.toContain('账号已开通'); }); + + it('BRANCH 2 — a number with NO row still takes the deployment default', async () => { + // ⚠️ Positive control for the zero: ONE engine, one table, two numbers. + // It answers zh-CN for the number that carries a row and nothing for + // the one that does not — which is what separates "the read ran and + // found nothing" from "the read never ran", since both would land on + // the deployment default and render identically. + const calls: any[] = []; + const engine = phoneKeyedEngine({ [PHONE]: 'zh-CN' }, calls); + + const known = await bootOtp({ dataEngine: engine }); + const knownSms = fakeSms(); + known.manager.setSmsService(knownSms.service); + known.manager.setDefaultSmsLocale('en-US'); + await known.manager.sendPhoneInviteSms(PHONE); + expect(knownSms.sent[0].body).toContain('账号已开通'); + + const fresh = await bootOtp({ dataEngine: engine }); + const freshSms = fakeSms(); + fresh.manager.setSmsService(freshSms.service); + fresh.manager.setDefaultSmsLocale('en-US'); + await fresh.manager.sendPhoneInviteSms(OTHER_PHONE); + expect(freshSms.sent[0].body).toContain('Sign in with this phone number'); + expect(freshSms.sent[0].body).not.toContain('账号已开通'); + + // ...and the second send's read really was attempted, on its number. + expect(calls.filter((c) => c.object === 'sys_user').map((c) => c.query.where)).toEqual([ + { phone_number: PHONE }, + { phone_number: OTHER_PHONE }, + ]); + }); + + it('reads the column projected, by phone number, under a system context', async () => { + const calls: any[] = []; + const { manager } = await bootOtp({ + dataEngine: phoneKeyedEngine({ [PHONE]: 'zh-CN' }, calls), + }); + manager.setSmsService(fakeSms().service); + manager.setDefaultSmsLocale('en-US'); + + await manager.sendPhoneInviteSms(PHONE); + 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 failing recipient read never blocks the invitation SMS', async () => { + const { manager } = 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 manager.sendPhoneInviteSms(PHONE); + expect(sms.sent).toHaveLength(1); + expect(sms.sent[0].body).toContain('账号已开通'); + }); }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 3b6add63c4..0faf7791f7 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -3048,6 +3048,31 @@ export class AuthManager { ); return; } + // #14641 — the invitee's OWN stored language, when the address + // already carries a `sys_user` row. Two branches, and the split is + // the whole point: + // + // 1. the address HAS a row — an existing platform user invited into + // a second organization, or a re-invitation (better-auth's + // `create-invitation` refuses only an address that is already a + // member of THIS org, measured in the installed 1.7.x + // `routes/crud-invites.mjs`, so an existing account being invited + // elsewhere reaches this callback normally). Their column is a + // language they chose for themselves — the same authority the + // reset / verification sends read since #14762. + // 2. NO row — a genuinely new invitee. Their language is still truly + // unknown at invitation time, so the deployment default stands, + // exactly as before. + // + // Matched EXACTLY on the address, which is safe here rather than + // merely tolerable: better-auth lowercases the invitee address on the + // invite route (`crud-invites.mjs` — both the create and the resend + // branch) and lowercases the stored `user.email` on sign-up + // (`api/routes/sign-up.mjs`), so both sides of this predicate are + // already in the same case. A spelling that resolves no row lands on + // the deployment default, which is the documented floor rather than a + // failure. + const storedLocale = await this.storedRecipientLocale({ email: recipientEmail }); try { await emailService.sendTemplate({ template: 'auth.invitation', @@ -3059,13 +3084,11 @@ export class AuthManager { // very card one seat over. The 2026-09-02 ruling enumerates // signup, sign-in and password reset — sends where the requester // IS the recipient — and the superseded 2026-08-13 ruling named - // invitations as its own counterexample. A per-user language - // now EXISTS to read — `sys_user.locale` (#13881, ruling - // 2026-09-01) — but an invitee has no `sys_user` row until they - // accept, so this send keeps the deployment rung; reading the - // invitee's column where one exists is #14641's rung, not this - // card's. - ...this.emailLocaleArg(), + // invitations as its own counterexample. #13881's ruling item 3 + // fixes the chain as RECIPIENT locale → deployment default, so + // the inviter direction stays rejected: what #14641 added above + // is the invitee's own column, never the inviter's header. + ...this.emailLocaleArg(undefined, storedLocale), data: { inviter: { name: inviter?.user?.name ?? inviter?.user?.email ?? 'A teammate', @@ -4573,8 +4596,10 @@ export class AuthManager { * 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.) + * ruling of 2026-09-03. (#14641 then gave the invitation sends the SAME top + * rung, on the address / phone number rather than a user id — an invitee who + * ALREADY holds a row gets their own language, and only a genuinely new + * invitee still takes the deployment default.) */ private async sendChangeEmailNotice( from: { email: string; name?: string; id?: string }, @@ -4778,11 +4803,32 @@ export class AuthManager { // `loginUrl` points at the actual Console sign-in page; `baseUrl` (bare // origin) is kept for backward-compatibility with tenant-overridden // templates that still interpolate `{{baseUrl}}`. - const body = await this.renderPhoneSmsBody(PHONE_SMS_TOPICS.invite, { - appName: this.getAppName(), - baseUrl: this.getCanonicalOrigin(), - loginUrl: this.getConsolePageUrl('/login'), - }); + // + // #14641 — the invitee's OWN stored language, on the same two branches the + // invitation EMAIL takes. A number that resolves no row, or a row naming no + // language, keeps the deployment default. Matched exactly on + // `phone_number`, the same predicate {@link deliverPhoneOtp} matches on. + // + // ⚠️ What this yields TODAY, so the next reader does not over-read it: the + // one in-repo caller (the identity import endpoint's `invite` policy) + // CREATES the account before sending, so a ROW is always there — but + // `admin-import-users.ts` never writes `locale` and the column declares no + // default, so the value is empty at send time and this path still resolves + // to the deployment default, exactly as it did before #14641. The rung is + // wired, not yet exercised in-repo: it answers for an out-of-repo caller, + // or for a future import that populates the column. ⛔ Do not read this + // site as a behaviour change users see today — that one is on the + // invitation EMAIL, whose invitee may already hold a locale-bearing row. + const storedLocale = await this.storedRecipientLocale({ phone_number: phone }); + const body = await this.renderPhoneSmsBody( + PHONE_SMS_TOPICS.invite, + { + appName: this.getAppName(), + baseUrl: this.getCanonicalOrigin(), + loginUrl: this.getConsolePageUrl('/login'), + }, + storedLocale, + ); const result = await sms.send({ to: phone, body, templateParams: { content: body } }); if (result.status === 'failed') { // #6039 — same quota wall, same outward shape as the OTP path above. @@ -4810,8 +4856,11 @@ export class AuthManager { * #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. + * and falls here when the account holds none. #14641 gave the SMS INVITE + * path the same rung, matched on `phone_number` — though the one in-repo + * caller (identity import) creates a row that never carries a `locale`, so + * that flow still lands HERE in practice. This rung answers when the + * number resolves no row, or the row names no language. */ setDefaultSmsLocale(locale: string | undefined): void { this.smsLocale = locale?.trim() || undefined; @@ -4862,8 +4911,11 @@ export class AuthManager { * 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). + * is every send to an account that never set one. #14641 then reached the + * invitation send too — but only through the STORED rung, matched on the + * invitee's address: an invitation to an address with no `sys_user` row (the + * common case) still reads exactly this rung, and the inviter's + * `Accept-Language` remains rejected on that send. */ setDefaultEmailLocale(locale: string | undefined): void { this.emailLocale = normalizeAuthEmailLocale(locale); @@ -4883,10 +4935,16 @@ export class AuthManager { * 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. + * The read is one row on an indexed predicate, 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 #14762 was about, and the INVITER-triggered case + * #14641 added. Three predicates, one per caller shape: `sys_user.id` (the + * sends that hold a user row), the unique `phone_number` (the SMS sends, + * which are handed a number and nothing else), and the unique `email` (the + * invitation send, which is handed an address for an account that may not + * exist yet). All three are `unique: true` in the better-auth `user` table + * this object is backed by (`@better-auth/core` `db/get-tables.mjs`). */ private async storedRecipientLocale(where: Record): Promise { const engine = this.getDataEngine(); @@ -4959,8 +5017,10 @@ export class AuthManager { * 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. + * A caller that passes nothing — or one whose recipient resolves no row — + * gets exactly the pre-#14762 deployment-default behaviour. #14641 made the + * SMS invite path a passer rather than an abstainer; it is no longer the + * standing example of a caller that names nothing. */ private async renderPhoneSmsBody( topic: string, diff --git a/packages/plugins/plugin-auth/src/phone-sms-texts.ts b/packages/plugins/plugin-auth/src/phone-sms-texts.ts index ea870a7e77..fc33d49d02 100644 --- a/packages/plugins/plugin-auth/src/phone-sms-texts.ts +++ b/packages/plugins/plugin-auth/src/phone-sms-texts.ts @@ -22,8 +22,13 @@ * 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. + * #14641 gave the SMS **invite** path the same two rungs, matched on + * `phone_number`. A number that resolves no row — or a row naming no language + * — keeps the deployment default. ⚠️ That is what the one in-repo caller gets + * today: the identity import endpoint creates the account before sending, so a + * ROW is always there, but it never writes `locale` and the column has no + * default, so that flow still resolves to the deployment rung. The rung is + * wired for an out-of-repo caller, or a future import that populates it. * * Whatever arrives, {@link phoneSmsLocaleChain}'s terminal `en` remains the * floor: this module never returns nothing, and an OTP never fails to render.