diff --git a/.changeset/auth-email-locale-workspace-language.md b/.changeset/auth-email-locale-workspace-language.md new file mode 100644 index 0000000000..9862c80a5e --- /dev/null +++ b/.changeset/auth-email-locale-workspace-language.md @@ -0,0 +1,31 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(plugin-auth): bind the auth email locale to the workspace language, not the build-time default (#14319) + +Auth mail (verification, password reset, invitation, magic link, email-change +notice) picked its `sys_email_template` row from +`II18nService.getDefaultLocale()` alone — the app artifact's **build-time** +`i18n.defaultLocale`, which is the bare `en` unless the app declares otherwise. +The workspace's **runtime** language, `localization.locale` (ADR-0053, Setup ▸ +Localization), was never consulted, even though the very same `kernel:ready` +pass already reads it a dozen lines earlier to localize auth SMS, and the four +options that setting offers are exactly the four locales the auth templates +ship rows for. + +A workspace that switched itself to Chinese therefore received Chinese OTP +texts and English verification mail from one plugin, on one boot. + +`AuthPlugin` now prefers `localization.locale` whenever the operator has +**explicitly** set it (`ResolvedSettingValue.source !== 'default'`) — the same +precedence the sibling `branding.workspace_name` binding uses — and keeps the +build-time `i18n.defaultLocale` standing underneath it, so a deployment that +declared one is not demoted to the manifest default `en-US`. Neither producer +answering leaves the locale unnamed, which is `EmailService`'s documented +`en-US` fallback. The binding live-rebinds on `localization` settings changes, +exactly as the SMS one does. + +The 2026-08-13 ruling is unchanged: the recipient locale is the deployment +default, resolved at the plugin layer; `Accept-Language` stays rejected and +there is still no per-user locale. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index d1336849b9..0a89fad50b 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -97,7 +97,7 @@ that silently does not happen. | 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | -| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1301` | +| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1345` | | 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` | | 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` | | 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` | diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 2ae509b93c..54a3d29bae 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -4552,11 +4552,19 @@ export class AuthManager { * localized `sys_email_template` rows can be selected at all. * * Maintainer ruling 2026-08-13: the recipient locale is the **deployment - * default**, read from `II18nService.getDefaultLocale()` and resolved at the - * plugin layer; `Accept-Language` is rejected (auth mail is frequently sent - * outside the triggering request — invitations, admin-initiated resets — and - * a per-device header is the wrong authority for it). AuthPlugin pushes the - * value on `kernel:ready`, exactly as it pushes {@link setDefaultSmsLocale}. + * default**, resolved at the plugin layer; `Accept-Language` is rejected + * (auth mail is frequently sent outside the triggering request — + * invitations, admin-initiated resets — and a per-device header is the wrong + * authority for it). AuthPlugin pushes the value on `kernel:ready`, exactly + * as it pushes {@link setDefaultSmsLocale}. + * + * #14319 — that "deployment default" is the workspace's declared language, + * `localization.locale` (ADR-0053), whenever the operator has explicitly set + * one; `II18nService.getDefaultLocale()` (the app artifact's build-time + * `i18n.defaultLocale`) stands underneath it. Email read only the build-time + * half before, so a workspace that switched to Chinese in Setup received + * Chinese auth SMS and English auth mail. The precedence lives in + * `AuthPlugin`; this setter stays a plain sink. * * Unset ⇒ nothing is named and `EmailService`'s ladder resolves its * documented `en-US` default, i.e. today's behaviour. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index ff434c80f9..be4648c6d6 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -517,6 +517,169 @@ describe('AuthPlugin', () => { }); }); + /** + * #14319 — auth email must speak the workspace's language. + * + * The reported symptom was a Chinese workspace receiving an English + * verification subject. Two halves make that up, and they live apart: + * + * - the TEMPLATE half — a `zh-CN` row exists for each auth template and + * reads naturally — is `plugin-email/src/auth-templates-locales.test.ts`; + * - the SEND half — every auth send names the bound locale — is + * `auth-email-locale.test.ts`, which drives all five callbacks. + * + * This block owns the third and previously missing one: which value the + * plugin binds. Before #14319 it was `II18nService.getDefaultLocale()` + * alone — the app artifact's BUILD-TIME `i18n.defaultLocale`, `en` unless + * the app declares otherwise — while auth SMS (#2815) and audit activity + * summaries (framework#3039) read the workspace's RUNTIME language, + * `localization.locale` (ADR-0053). A workspace switched to Chinese in + * Setup therefore got Chinese SMS and English mail from the same plugin, + * on the same `kernel:ready` pass, a dozen lines apart. + */ + describe('Auth email locale binding (localization.locale) — #14319', () => { + let hookCapture: ReturnType; + let setEmailLocaleSpy: ReturnType; + let setSmsLocaleSpy: ReturnType; + + type Resolved = { value: unknown; source: string }; + + /** + * A settings double answering per `(namespace, key)`. `branding` resolves + * to its manifest default so the sibling brand binding is inert here, and + * `subscribe` records handlers so the live-rebind leg can fire one. + */ + const makeSettings = (locale: Resolved | Error) => { + const handlers: Array<() => void> = []; + let current = locale; + return { + handlers, + set(next: Resolved) { + current = next; + }, + get: vi.fn(async (namespace: string, key: string) => { + if (namespace === 'branding') return { value: 'ObjectStack', source: 'default' }; + if (namespace === 'localization' && key === 'locale') { + if (current instanceof Error) throw current; + return current; + } + return undefined; + }), + subscribe: vi.fn((_namespace: string | undefined, handler: () => void) => { + handlers.push(handler); + return () => {}; + }), + }; + }; + + /** + * `i18n` is passed as `null` to mean "no such service" — `getService` + * THROWS for an unregistered service, which is the shape the plugin + * probes for, not a falsy return. + */ + const boot = async (opts: { settings?: unknown; i18nDefault?: string | null }) => { + hookCapture = createHookCapture(); + mockContext.hook = hookCapture.hookFn; + // `: any` on the RETURN, not a cast on the assignment: `getService` is + // generic (`(name: string) => T`), so an inferred union return is a + // TS2322 — the exact debt `check:test-typecheck` ledgers for the older + // doubles in this file. That ledger only ratchets down, so this one + // states its shape instead of adding to it. + mockContext.getService = vi.fn((name: string): any => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'settings') { + if (opts.settings === undefined) throw new Error('Service not found: settings'); + return opts.settings; + } + if (name === 'i18n') { + if (opts.i18nDefault === undefined) throw new Error('Service not found: i18n'); + return { getDefaultLocale: () => opts.i18nDefault }; + } + return undefined; + }); + setEmailLocaleSpy = vi.spyOn(AuthManager.prototype, 'setDefaultEmailLocale'); + setSmsLocaleSpy = vi.spyOn(AuthManager.prototype, 'setDefaultSmsLocale'); + authPlugin = new AuthPlugin({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + }); + await authPlugin.init(mockContext); + await authPlugin.start(mockContext); + await hookCapture.trigger('kernel:ready'); + }; + + afterEach(() => { + setEmailLocaleSpy?.mockRestore(); + setSmsLocaleSpy?.mockRestore(); + }); + + it('a zh-CN workspace binds zh-CN on the EMAIL channel, not just on SMS', async () => { + const settings = makeSettings({ value: 'zh-CN', source: 'tenant' }); + await boot({ settings, i18nDefault: 'en' }); + + expect(settings.get).toHaveBeenCalledWith('localization', 'locale', {}); + // The regression, stated as the two channels agreeing. Before #14319 the + // SMS assertion passed and the email one read 'en'. + expect(setSmsLocaleSpy).toHaveBeenLastCalledWith('zh-CN'); + expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('zh-CN'); + }); + + it.each(['ja-JP', 'es-ES'])('and the same for a %s workspace', async (locale) => { + await boot({ settings: makeSettings({ value: locale, source: 'global' }), i18nDefault: 'en' }); + expect(setEmailLocaleSpy).toHaveBeenLastCalledWith(locale); + }); + + it('a workspace that never chose a language keeps the app build-time default', async () => { + // `get` answers the manifest default ('en-US') for an untouched + // workspace, so taking `value` unconditionally would demote every + // deployment that declared `i18n.defaultLocale` — the #8195 behaviour + // this change must not regress. + await boot({ + settings: makeSettings({ value: 'en-US', source: 'default' }), + i18nDefault: 'zh-CN', + }); + expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('zh-CN'); + }); + + it('names NO locale when neither producer speaks — the documented en-US fallback', async () => { + // The issue's second acceptance criterion: absent language ⇒ English. + // Spelled as an ABSENT locale rather than 'en-US', because that is what + // `EmailService`'s ladder contract ("no locale means the DOCUMENTED + // default") is written against. + await boot({ settings: makeSettings({ value: 'en-US', source: 'default' }) }); + expect(setEmailLocaleSpy).toHaveBeenLastCalledWith(undefined); + }); + + it('binds the build-time default when there is no settings service at all', async () => { + await boot({ i18nDefault: 'ja-JP' }); + expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('ja-JP'); + }); + + it('re-binds live when the workspace switches language', async () => { + const settings = makeSettings({ value: 'en-US', source: 'default' }); + await boot({ settings, i18nDefault: 'en' }); + expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('en'); + + expect(settings.subscribe).toHaveBeenCalledWith('localization', expect.any(Function)); + settings.set({ value: 'zh-CN', source: 'tenant' }); + for (const handler of settings.handlers) handler(); + // The subscribe handler is fire-and-forget (`void`); flush its promise. + await new Promise((resolve) => setImmediate(resolve)); + + expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('zh-CN'); + expect(setSmsLocaleSpy).toHaveBeenLastCalledWith('zh-CN'); + }); + + it('leaves the build-time default standing when the settings read fails', async () => { + const settings = makeSettings(new Error('boom')); + await expect(boot({ settings, i18nDefault: 'zh-CN' })).resolves.toBeUndefined(); + expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('zh-CN'); + expect(mockContext.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('failed to apply localization.locale'), + ); + }); + }); + describe('Auth settings binding (auth namespace)', () => { let hookCapture: ReturnType; const previousGoogleClientId = process.env.GOOGLE_CLIENT_ID; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 8d8137058e..7826ea6fe0 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -777,6 +777,64 @@ export class AuthPlugin implements Plugin { ctx.logger.info('Auth: no sms service registered — phone-number OTP disabled (password sign-in only)'); } + // #8195 / #14319 — the locale named on every auth EMAIL, so the + // localized `sys_email_template` rows are reachable through the + // platform's own send path instead of sitting dormant. + // + // Maintainer ruling 2026-08-13: the recipient locale is the + // **deployment default**, resolved here at the plugin layer. + // `Accept-Language` is rejected — auth mail is routinely sent + // outside the triggering request (invitations, admin-initiated + // resets), so a per-device request header is the wrong authority. + // + // #14319 measured that "the deployment default" has TWO producers, + // and that auth email was reading the weaker one: + // + // - `II18nService.getDefaultLocale()` is the app artifact's + // BUILD-TIME `i18n.defaultLocale` (`AppPlugin` pushes it through + // `setDefaultLocale`; the CLI passes `config.i18n.defaultLocale`), + // and is the bare `en` when the app declares nothing. + // - `localization.locale` (ADR-0053) is the workspace's RUNTIME + // language — Setup, Localization, tenant-scoped — whose four + // options are exactly `AUTH_EMAIL_TEMPLATE_LOCALES`. It is + // already the authority for auth SMS (#2815) and for audit + // activity summaries (framework#3039). + // + // A workspace that declared Chinese therefore received Chinese SMS + // and ENGLISH mail. The workspace setting now outranks the + // build-time default, and only when it is EXPLICIT + // (`source !== 'default'`) — the same precedence the `appName` + // binding below uses, because `get` answers the manifest default + // (`en-US`) for an untouched workspace and taking it unconditionally + // would demote every deployment that declared `i18n.defaultLocale`. + // + // Both i18n hops are probed rather than assumed: `getService` THROWS + // for an unregistered service (i18n is not a required dependency of + // auth) and `getDefaultLocale` is OPTIONAL on the contract. Nothing + // resolvable ⇒ nothing is named, which is precisely the pre-#8195 + // behaviour — `EmailService` resolves its documented `en-US` default. + let i18nDefaultEmailLocale: string | undefined; + try { + const i18n = ctx.getService('i18n'); + const locale = + typeof i18n?.getDefaultLocale === 'function' ? i18n.getDefaultLocale() : undefined; + i18nDefaultEmailLocale = typeof locale === 'string' ? locale : undefined; + } catch { + // i18n service is optional — leave the build-time default unset. + } + const applyEmailLocale = (workspaceLocale: string | undefined): void => { + this.authManager?.setDefaultEmailLocale(workspaceLocale ?? i18nDefaultEmailLocale); + }; + // Baseline = the build-time default. Superseded below when a settings + // service answers with an explicit workspace language, and left + // standing when there is no settings service or its read fails. + applyEmailLocale(undefined); + if (typeof i18nDefaultEmailLocale === 'string' && i18nDefaultEmailLocale.trim()) { + ctx.logger.info( + `Auth: bound auth email locale to i18n default=${i18nDefaultEmailLocale}`, + ); + } + // Bind the email brand name (`{{appName}}`) to the live // `branding.workspace_name` setting so the admin UI can rename the // product without a redeploy. Only an *explicitly set* value @@ -812,24 +870,40 @@ export class AuthPlugin implements Plugin { // #2815 — bind the auth SMS locale to the deployment default // (`localization.locale`) so OTP/invitation texts render in the - // workspace language. Live-rebinds on settings changes. - const applySmsLocale = async () => { + // workspace language. #14319 binds the auth EMAIL locale from + // the very same read, so the two auth channels can no longer + // disagree about what language this workspace speaks. + // Live-rebinds on settings changes. + const applyLocalizationLocale = async () => { try { const resolved = await settings.get('localization', 'locale', {}); const value = resolved?.value; this.authManager?.setDefaultSmsLocale( typeof value === 'string' ? value : undefined, ); + // Email takes the workspace language only when it was + // EXPLICITLY set; a manifest default leaves the app's + // build-time `i18n.defaultLocale` standing (block above). + const explicit = + resolved && resolved.source !== 'default' && typeof value === 'string' + ? value + : undefined; + applyEmailLocale(explicit); + if (explicit) { + ctx.logger.info( + `Auth: bound auth email locale to localization.locale=${explicit}`, + ); + } } catch (err: any) { ctx.logger.warn( 'Auth: failed to apply localization.locale: ' + (err?.message ?? err), ); } }; - await applySmsLocale(); + await applyLocalizationLocale(); if (typeof settings.subscribe === 'function') { settings.subscribe('localization', () => { - void applySmsLocale(); + void applyLocalizationLocale(); }); } } @@ -837,36 +911,6 @@ export class AuthPlugin implements Plugin { // settings service is optional — keep the configured appName. } - // #8195 — name the deployment-default locale on every auth EMAIL, so - // the localized `sys_email_template` rows become reachable through - // the platform's own send path instead of sitting dormant. - // - // Maintainer ruling 2026-08-13: the source is - // `II18nService.getDefaultLocale()`, resolved here at the plugin - // layer. `Accept-Language` is rejected — auth mail is routinely sent - // outside the triggering request (invitations, admin-initiated - // resets), so a per-device request header is the wrong authority. - // - // Both hops are probed rather than assumed: `getService` THROWS for - // an unregistered service (i18n is not a required dependency of - // auth), and `getDefaultLocale` is OPTIONAL on the contract. Either - // one missing must leave the locale unset, which is precisely the - // pre-#8195 behaviour — `EmailService` resolves its documented - // `en-US` default. - try { - const i18n = ctx.getService('i18n'); - const locale = - typeof i18n?.getDefaultLocale === 'function' ? i18n.getDefaultLocale() : undefined; - this.authManager?.setDefaultEmailLocale( - typeof locale === 'string' ? locale : undefined, - ); - if (typeof locale === 'string' && locale.trim()) { - ctx.logger.info(`Auth: bound auth email locale to i18n default=${locale}`); - } - } catch { - // i18n service is optional — leave the email locale unset. - } - // #2815 — seed the built-in bilingual auth SMS templates into // sys_notification_template (insert-if-missing; tenant edits are // never overwritten). Only meaningful when phone sign-in is on;