Skip to content

Commit a5b95ee

Browse files
hotlongclaude
andauthored
fix(plugin-auth): bind the auth email locale to the workspace language, not the build-time default (#14591)
* fix(plugin-auth): bind the auth email locale to the workspace language, not the build-time default Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(plugin-auth): pin the workspace-language email binding; add changeset Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(permissions): re-anchor the system-context census row after the auth-plugin edit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent d80140c commit a5b95ee

5 files changed

Lines changed: 286 additions & 40 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): bind the auth email locale to the workspace language, not the build-time default (#14319)
6+
7+
Auth mail (verification, password reset, invitation, magic link, email-change
8+
notice) picked its `sys_email_template` row from
9+
`II18nService.getDefaultLocale()` alone — the app artifact's **build-time**
10+
`i18n.defaultLocale`, which is the bare `en` unless the app declares otherwise.
11+
The workspace's **runtime** language, `localization.locale` (ADR-0053, Setup ▸
12+
Localization), was never consulted, even though the very same `kernel:ready`
13+
pass already reads it a dozen lines earlier to localize auth SMS, and the four
14+
options that setting offers are exactly the four locales the auth templates
15+
ship rows for.
16+
17+
A workspace that switched itself to Chinese therefore received Chinese OTP
18+
texts and English verification mail from one plugin, on one boot.
19+
20+
`AuthPlugin` now prefers `localization.locale` whenever the operator has
21+
**explicitly** set it (`ResolvedSettingValue.source !== 'default'`) — the same
22+
precedence the sibling `branding.workspace_name` binding uses — and keeps the
23+
build-time `i18n.defaultLocale` standing underneath it, so a deployment that
24+
declared one is not demoted to the manifest default `en-US`. Neither producer
25+
answering leaves the locale unnamed, which is `EmailService`'s documented
26+
`en-US` fallback. The binding live-rebinds on `localization` settings changes,
27+
exactly as the SMS one does.
28+
29+
The 2026-08-13 ruling is unchanged: the recipient locale is the deployment
30+
default, resolved at the plugin layer; `Accept-Language` stays rejected and
31+
there is still no per-user locale.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ that silently does not happen.
9797
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` |
9898
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
9999
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
100-
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1301` |
100+
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1345` |
101101
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
102102
| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4552,11 +4552,19 @@ export class AuthManager {
45524552
* localized `sys_email_template` rows can be selected at all.
45534553
*
45544554
* Maintainer ruling 2026-08-13: the recipient locale is the **deployment
4555-
* default**, read from `II18nService.getDefaultLocale()` and resolved at the
4556-
* plugin layer; `Accept-Language` is rejected (auth mail is frequently sent
4557-
* outside the triggering request — invitations, admin-initiated resets — and
4558-
* a per-device header is the wrong authority for it). AuthPlugin pushes the
4559-
* value on `kernel:ready`, exactly as it pushes {@link setDefaultSmsLocale}.
4555+
* default**, resolved at the plugin layer; `Accept-Language` is rejected
4556+
* (auth mail is frequently sent outside the triggering request —
4557+
* invitations, admin-initiated resets — and a per-device header is the wrong
4558+
* authority for it). AuthPlugin pushes the value on `kernel:ready`, exactly
4559+
* as it pushes {@link setDefaultSmsLocale}.
4560+
*
4561+
* #14319 — that "deployment default" is the workspace's declared language,
4562+
* `localization.locale` (ADR-0053), whenever the operator has explicitly set
4563+
* one; `II18nService.getDefaultLocale()` (the app artifact's build-time
4564+
* `i18n.defaultLocale`) stands underneath it. Email read only the build-time
4565+
* half before, so a workspace that switched to Chinese in Setup received
4566+
* Chinese auth SMS and English auth mail. The precedence lives in
4567+
* `AuthPlugin`; this setter stays a plain sink.
45604568
*
45614569
* Unset ⇒ nothing is named and `EmailService`'s ladder resolves its
45624570
* documented `en-US` default, i.e. today's behaviour.

packages/plugins/plugin-auth/src/auth-plugin.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,169 @@ describe('AuthPlugin', () => {
517517
});
518518
});
519519

520+
/**
521+
* #14319 — auth email must speak the workspace's language.
522+
*
523+
* The reported symptom was a Chinese workspace receiving an English
524+
* verification subject. Two halves make that up, and they live apart:
525+
*
526+
* - the TEMPLATE half — a `zh-CN` row exists for each auth template and
527+
* reads naturally — is `plugin-email/src/auth-templates-locales.test.ts`;
528+
* - the SEND half — every auth send names the bound locale — is
529+
* `auth-email-locale.test.ts`, which drives all five callbacks.
530+
*
531+
* This block owns the third and previously missing one: which value the
532+
* plugin binds. Before #14319 it was `II18nService.getDefaultLocale()`
533+
* alone — the app artifact's BUILD-TIME `i18n.defaultLocale`, `en` unless
534+
* the app declares otherwise — while auth SMS (#2815) and audit activity
535+
* summaries (framework#3039) read the workspace's RUNTIME language,
536+
* `localization.locale` (ADR-0053). A workspace switched to Chinese in
537+
* Setup therefore got Chinese SMS and English mail from the same plugin,
538+
* on the same `kernel:ready` pass, a dozen lines apart.
539+
*/
540+
describe('Auth email locale binding (localization.locale) — #14319', () => {
541+
let hookCapture: ReturnType<typeof createHookCapture>;
542+
let setEmailLocaleSpy: ReturnType<typeof vi.spyOn>;
543+
let setSmsLocaleSpy: ReturnType<typeof vi.spyOn>;
544+
545+
type Resolved = { value: unknown; source: string };
546+
547+
/**
548+
* A settings double answering per `(namespace, key)`. `branding` resolves
549+
* to its manifest default so the sibling brand binding is inert here, and
550+
* `subscribe` records handlers so the live-rebind leg can fire one.
551+
*/
552+
const makeSettings = (locale: Resolved | Error) => {
553+
const handlers: Array<() => void> = [];
554+
let current = locale;
555+
return {
556+
handlers,
557+
set(next: Resolved) {
558+
current = next;
559+
},
560+
get: vi.fn(async (namespace: string, key: string) => {
561+
if (namespace === 'branding') return { value: 'ObjectStack', source: 'default' };
562+
if (namespace === 'localization' && key === 'locale') {
563+
if (current instanceof Error) throw current;
564+
return current;
565+
}
566+
return undefined;
567+
}),
568+
subscribe: vi.fn((_namespace: string | undefined, handler: () => void) => {
569+
handlers.push(handler);
570+
return () => {};
571+
}),
572+
};
573+
};
574+
575+
/**
576+
* `i18n` is passed as `null` to mean "no such service" — `getService`
577+
* THROWS for an unregistered service, which is the shape the plugin
578+
* probes for, not a falsy return.
579+
*/
580+
const boot = async (opts: { settings?: unknown; i18nDefault?: string | null }) => {
581+
hookCapture = createHookCapture();
582+
mockContext.hook = hookCapture.hookFn;
583+
// `: any` on the RETURN, not a cast on the assignment: `getService` is
584+
// generic (`<T>(name: string) => T`), so an inferred union return is a
585+
// TS2322 — the exact debt `check:test-typecheck` ledgers for the older
586+
// doubles in this file. That ledger only ratchets down, so this one
587+
// states its shape instead of adding to it.
588+
mockContext.getService = vi.fn((name: string): any => {
589+
if (name === 'manifest') return { register: vi.fn() };
590+
if (name === 'settings') {
591+
if (opts.settings === undefined) throw new Error('Service not found: settings');
592+
return opts.settings;
593+
}
594+
if (name === 'i18n') {
595+
if (opts.i18nDefault === undefined) throw new Error('Service not found: i18n');
596+
return { getDefaultLocale: () => opts.i18nDefault };
597+
}
598+
return undefined;
599+
});
600+
setEmailLocaleSpy = vi.spyOn(AuthManager.prototype, 'setDefaultEmailLocale');
601+
setSmsLocaleSpy = vi.spyOn(AuthManager.prototype, 'setDefaultSmsLocale');
602+
authPlugin = new AuthPlugin({
603+
secret: 'test-secret-at-least-32-chars-long',
604+
baseUrl: 'http://localhost:3000',
605+
});
606+
await authPlugin.init(mockContext);
607+
await authPlugin.start(mockContext);
608+
await hookCapture.trigger('kernel:ready');
609+
};
610+
611+
afterEach(() => {
612+
setEmailLocaleSpy?.mockRestore();
613+
setSmsLocaleSpy?.mockRestore();
614+
});
615+
616+
it('a zh-CN workspace binds zh-CN on the EMAIL channel, not just on SMS', async () => {
617+
const settings = makeSettings({ value: 'zh-CN', source: 'tenant' });
618+
await boot({ settings, i18nDefault: 'en' });
619+
620+
expect(settings.get).toHaveBeenCalledWith('localization', 'locale', {});
621+
// The regression, stated as the two channels agreeing. Before #14319 the
622+
// SMS assertion passed and the email one read 'en'.
623+
expect(setSmsLocaleSpy).toHaveBeenLastCalledWith('zh-CN');
624+
expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('zh-CN');
625+
});
626+
627+
it.each(['ja-JP', 'es-ES'])('and the same for a %s workspace', async (locale) => {
628+
await boot({ settings: makeSettings({ value: locale, source: 'global' }), i18nDefault: 'en' });
629+
expect(setEmailLocaleSpy).toHaveBeenLastCalledWith(locale);
630+
});
631+
632+
it('a workspace that never chose a language keeps the app build-time default', async () => {
633+
// `get` answers the manifest default ('en-US') for an untouched
634+
// workspace, so taking `value` unconditionally would demote every
635+
// deployment that declared `i18n.defaultLocale` — the #8195 behaviour
636+
// this change must not regress.
637+
await boot({
638+
settings: makeSettings({ value: 'en-US', source: 'default' }),
639+
i18nDefault: 'zh-CN',
640+
});
641+
expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('zh-CN');
642+
});
643+
644+
it('names NO locale when neither producer speaks — the documented en-US fallback', async () => {
645+
// The issue's second acceptance criterion: absent language ⇒ English.
646+
// Spelled as an ABSENT locale rather than 'en-US', because that is what
647+
// `EmailService`'s ladder contract ("no locale means the DOCUMENTED
648+
// default") is written against.
649+
await boot({ settings: makeSettings({ value: 'en-US', source: 'default' }) });
650+
expect(setEmailLocaleSpy).toHaveBeenLastCalledWith(undefined);
651+
});
652+
653+
it('binds the build-time default when there is no settings service at all', async () => {
654+
await boot({ i18nDefault: 'ja-JP' });
655+
expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('ja-JP');
656+
});
657+
658+
it('re-binds live when the workspace switches language', async () => {
659+
const settings = makeSettings({ value: 'en-US', source: 'default' });
660+
await boot({ settings, i18nDefault: 'en' });
661+
expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('en');
662+
663+
expect(settings.subscribe).toHaveBeenCalledWith('localization', expect.any(Function));
664+
settings.set({ value: 'zh-CN', source: 'tenant' });
665+
for (const handler of settings.handlers) handler();
666+
// The subscribe handler is fire-and-forget (`void`); flush its promise.
667+
await new Promise((resolve) => setImmediate(resolve));
668+
669+
expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('zh-CN');
670+
expect(setSmsLocaleSpy).toHaveBeenLastCalledWith('zh-CN');
671+
});
672+
673+
it('leaves the build-time default standing when the settings read fails', async () => {
674+
const settings = makeSettings(new Error('boom'));
675+
await expect(boot({ settings, i18nDefault: 'zh-CN' })).resolves.toBeUndefined();
676+
expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('zh-CN');
677+
expect(mockContext.logger.warn).toHaveBeenCalledWith(
678+
expect.stringContaining('failed to apply localization.locale'),
679+
);
680+
});
681+
});
682+
520683
describe('Auth settings binding (auth namespace)', () => {
521684
let hookCapture: ReturnType<typeof createHookCapture>;
522685
const previousGoogleClientId = process.env.GOOGLE_CLIENT_ID;

0 commit comments

Comments
 (0)