Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/auth-stored-recipient-locale-rungs.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 6 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
174 changes: 169 additions & 5 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
});
});
133 changes: 133 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading