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
37 changes: 37 additions & 0 deletions .changeset/auth-email-accept-language.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/plugin-auth": minor
---

feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319)

Request-triggered auth email — signup verification, password reset, magic link,
and the change-email notice — now picks its `sys_email_template` row from the
requesting caller's `Accept-Language`, falling back to the deployment default
(`localization.locale`, then `i18n.defaultLocale`) and finally to
`EmailService`'s documented `en-US`.

The motivating case is the one no deployment default can answer: at cloud
self-service signup there is no workspace yet, so nothing on the server
represents that person's language — a Chinese browser reached a Chinese signup
screen and received an English verification email.

The header is parsed by the platform's existing `preferredLocaleFromHeader`,
the same function REST uses for metadata translation and the runtime dispatcher
uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the
screen that triggered it. A requested locale takes effect only when it names one
of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything
else falls through rather than naming a row that does not exist.

Two deliberate exclusions. **Invitations keep the deployment default**:
better-auth hands that callback a request too, but it is the *inviter's*, and
stamping their browser language onto the invitee's mail would reproduce this
same defect one seat over. **Per-user language stays deferred** — `sys_user`
grows no locale column here.

This ships as `minor` because it changes which template row an existing
deployment sends: a workspace whose users' browsers ask for a different language
than the workspace declares will now send in the browser's language.

**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had
rejected `Accept-Language` outright. Both are recorded, with the older one
marked superseded, on `AuthManager.setDefaultEmailLocale`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:1345` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
| 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` |
Expand Down
213 changes: 208 additions & 5 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,24 +3,30 @@
/**
* #8195 — every auth email names the deployment-default locale.
*
* 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; no `sys_user.locale` column.
* Maintainer ruling 2026-09-02 (#14319), which SUPERSEDED the 2026-08-13 one
* this file was written against: a request-triggered auth email takes the
* 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. Still no `sys_user.locale` column —
* that half stayed deferred. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the
* request rung's own cases are the last describe block 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
* were unreachable through the platform's own send path — a zh-CN deployment
* received English credential mail while its UI spoke Chinese.
*
* This file owns the SENDING half: that all five sites name the locale, that
* This file owns the SENDING half: that all five sites name a locale, that
* an unconfigured deployment still names nothing, and that the catalog spelling
* (`en`) is mapped onto the row spelling (`en-US`). The template half — that a
* row actually exists in each locale and reads naturally — is
* `plugin-email/src/auth-templates-locales.test.ts`.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager, normalizeAuthEmailLocale } from './auth-manager';
import { AuthManager, normalizeAuthEmailLocale, authEmailLocaleFromRequest } from './auth-manager';

vi.mock('better-auth', () => ({
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
Expand DownExpand Up@@ -252,3 +258,200 @@ describe('#8195 — normalizeAuthEmailLocale', () => {
for (const input of sent) expect(input.locale).toBe('en-US');
});
});

// ── #14319 — the request rung ──────────────────────────────────────────────

/**
* Maintainer ruling 2026-09-02, quoted verbatim and untranslated:
*
* > 注册 / 登录 / 重置密码等由请求触发的 auth 邮件,语言优先取请求的
* > `Accept-Language`(命中 `AUTH_EMAIL_TEMPLATE_LOCALES` 才生效),其次才是
* > 部署默认(`localization.locale` → `i18n.defaultLocale`)。
*
* Asserted at the LOCALE named on the send, which is this layer's whole output.
* That a `zh-CN` row then renders a Chinese subject and no en-US text is
* `plugin-email/src/auth-templates-locales.test.ts`, which owns the row half;
* the two together are the card's acceptance criterion.
*
* The four sends driven here are the ones where the requester IS the recipient.
* The invitation is asserted to ABSTAIN in its own case below — better-auth
* hands it a request too, but it is the inviter's.
*/
async function driveWithHeader(
deploymentLocale: string | undefined,
header: string | undefined,
) {
const { capturedConfig, sent } = await boot(deploymentLocale);
const request =
header === undefined
? undefined
: new Request('http://x/any', { headers: { 'accept-language': header } });

// Measured better-auth 1.7.x shapes, NOT assumed: reset / verify / invitation
// receive `ctx.request`, magic-link receives the endpoint `ctx`, and the
// change-email notice fires from the global after-hook's `ctx`.
await capturedConfig.emailAndPassword.sendResetPassword(
{ user: USER, url: 'http://x/reset', token: 't' },
request,
);
await capturedConfig.emailVerification.sendVerificationEmail(
{ user: USER, url: 'http://x/verify', token: 't' },
request,
);

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' } },
},
request,
);

const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
await magic._opts.sendMagicLink(
{ email: 'ada@example.com', url: 'http://x/magic', token: 't' },
request ? { request } : undefined,
);

await capturedConfig.hooks.after({
path: '/change-email',
body: { newEmail: 'new@example.com' },
request,
context: {
__osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' },
returned: { status: true },
},
});

const byTemplate = (name: string) => sent.find((x: any) => x.template === name);
return {
sent,
/** The four sends whose recipient is the requester. */
requesterIsRecipient: [
'auth.password_reset',
'auth.verify_email',
'auth.magic_link',
'auth.email_change_notice',
].map((t) => byTemplate(t)!),
invitation: byTemplate('auth.invitation')!,
};
}

describe('#14319 — Accept-Language outranks the deployment default', () => {
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('a zh-CN caller gets zh-CN even though the deployment speaks English', async () => {
// The card's repro: Chinese browser, English deployment default. Before
// this ruling every one of these read `en-US`.
const { sent, requesterIsRecipient } = await driveWithHeader('en', 'zh-CN,zh;q=0.9,en;q=0.8');
// A send that never happened would make the locale assertion vacuous.
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) {
expect(input.locale, `${input.template} did not follow the request`).toBe('zh-CN');
}
});

it.each(['ja-JP', 'es-ES', 'en-US'])('and the same for a %s caller', async (tag) => {
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe(tag);
});

it('a caller who asked for nothing falls back to the deployment default', async () => {
const { sent, requesterIsRecipient } = await driveWithHeader('zh-CN', undefined);
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
});

it.each(['fr-FR', 'de', 'pt-BR', '*'])(
'a caller asking for %s — a locale we ship no auth row for — falls back to the deployment default',
async (tag) => {
// The ruling's "命中 AUTH_EMAIL_TEMPLATE_LOCALES 才生效" half. Honouring
// an unshipped tag would name a row that does not exist, which is the
// row-locale vs filter-locale split all over again.
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
},
);

it('with NO deployment default and an unshipped request, nothing is named at all', async () => {
// Both rungs silent ⇒ absent key, which is what EmailService's ladder
// contract ("no locale means the DOCUMENTED default") is written against.
const { requesterIsRecipient } = await driveWithHeader(undefined, 'fr-FR');
for (const input of requesterIsRecipient) {
expect(input.locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(input, 'locale')).toBe(false);
}
});

it('the INVITATION abstains — the request belongs to the inviter, not the invitee', async () => {
const { invitation } = await driveWithHeader('zh-CN', 'en-US');
// An English-speaking admin must not force English on their Chinese
// workspace's invitees; this send keeps the deployment rung.
expect(invitation.locale).toBe('zh-CN');
});

it('naming a request locale does not disturb the rest of the payload', async () => {
const { requesterIsRecipient } = await driveWithHeader('en', 'zh-CN');
const reset = requesterIsRecipient.find((x: any) => x.template === 'auth.password_reset')!;
expect(reset.data.resetUrl).toBe('http://x/reset');
expect(reset.relatedObject).toBe('sys_user');
expect(reset.relatedId).toBe('u1');
});
});

describe('#14319 — authEmailLocaleFromRequest', () => {
it('reads a Web Request and strips the quality weights', () => {
const req = new Request('http://x/', { headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' } });
expect(authEmailLocaleFromRequest(req)).toBe('zh-CN');
});

it('reads a better-auth endpoint ctx too — the shape sendMagicLink is handed', () => {
// Measured, not assumed: magic-link/index.mjs calls `sendMagicLink({...}, ctx)`.
const req = new Request('http://x/', { headers: { 'accept-language': 'ja-JP' } });
expect(authEmailLocaleFromRequest({ request: req })).toBe('ja-JP');
});

it('reads a plain header bag, either spelling', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'es-ES' } })).toBe('es-ES');
expect(authEmailLocaleFromRequest({ headers: { 'Accept-Language': 'es-ES' } })).toBe('es-ES');
});

it('promotes a bare language to the row we ship for it', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'zh' } })).toBe('zh-CN');
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en' } })).toBe('en-US');
});

it('refuses a locale we ship no auth row for, rather than naming a missing row', () => {
for (const tag of ['fr-FR', 'de', 'pt-BR', 'en-GB']) {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': tag } })).toBeUndefined();
}
});

it('treats an absent, empty or wildcard header as no preference', () => {
expect(authEmailLocaleFromRequest(undefined)).toBeUndefined();
expect(authEmailLocaleFromRequest(null)).toBeUndefined();
expect(authEmailLocaleFromRequest({})).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: {} })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '' } })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '*' } })).toBeUndefined();
});

it('never throws when the header bag itself is hostile', () => {
// A vendor changing the shape it hands a callback must degrade to the
// deployment default, never fail the send.
const hostile = { headers: { get() { throw new Error('boom'); } } };
expect(() => authEmailLocaleFromRequest(hostile)).not.toThrow();
expect(authEmailLocaleFromRequest(hostile)).toBeUndefined();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
37 changes: 37 additions & 0 deletions .changeset/auth-email-accept-language.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/plugin-auth": minor
---

feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319)

Request-triggered auth email — signup verification, password reset, magic link,
and the change-email notice — now picks its `sys_email_template` row from the
requesting caller's `Accept-Language`, falling back to the deployment default
(`localization.locale`, then `i18n.defaultLocale`) and finally to
`EmailService`'s documented `en-US`.

The motivating case is the one no deployment default can answer: at cloud
self-service signup there is no workspace yet, so nothing on the server
represents that person's language — a Chinese browser reached a Chinese signup
screen and received an English verification email.

The header is parsed by the platform's existing `preferredLocaleFromHeader`,
the same function REST uses for metadata translation and the runtime dispatcher
uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the
screen that triggered it. A requested locale takes effect only when it names one
of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything
else falls through rather than naming a row that does not exist.

Two deliberate exclusions. **Invitations keep the deployment default**:
better-auth hands that callback a request too, but it is the *inviter's*, and
stamping their browser language onto the invitee's mail would reproduce this
same defect one seat over. **Per-user language stays deferred** — `sys_user`
grows no locale column here.

This ships as `minor` because it changes which template row an existing
deployment sends: a workspace whose users' browsers ask for a different language
than the workspace declares will now send in the browser's language.

**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had
rejected `Accept-Language` outright. Both are recorded, with the older one
marked superseded, on `AuthManager.setDefaultEmailLocale`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:1345` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
| 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` |
Expand Down
213 changes: 208 additions & 5 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,24 +3,30 @@
/**
* #8195 — every auth email names the deployment-default locale.
*
* 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; no `sys_user.locale` column.
* Maintainer ruling 2026-09-02 (#14319), which SUPERSEDED the 2026-08-13 one
* this file was written against: a request-triggered auth email takes the
* 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. Still no `sys_user.locale` column —
* that half stayed deferred. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the
* request rung's own cases are the last describe block 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
* were unreachable through the platform's own send path — a zh-CN deployment
* received English credential mail while its UI spoke Chinese.
*
* This file owns the SENDING half: that all five sites name the locale, that
* This file owns the SENDING half: that all five sites name a locale, that
* an unconfigured deployment still names nothing, and that the catalog spelling
* (`en`) is mapped onto the row spelling (`en-US`). The template half — that a
* row actually exists in each locale and reads naturally — is
* `plugin-email/src/auth-templates-locales.test.ts`.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager, normalizeAuthEmailLocale } from './auth-manager';
import { AuthManager, normalizeAuthEmailLocale, authEmailLocaleFromRequest } from './auth-manager';

vi.mock('better-auth', () => ({
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
Expand DownExpand Up@@ -252,3 +258,200 @@ describe('#8195 — normalizeAuthEmailLocale', () => {
for (const input of sent) expect(input.locale).toBe('en-US');
});
});

// ── #14319 — the request rung ──────────────────────────────────────────────

/**
* Maintainer ruling 2026-09-02, quoted verbatim and untranslated:
*
* > 注册 / 登录 / 重置密码等由请求触发的 auth 邮件,语言优先取请求的
* > `Accept-Language`(命中 `AUTH_EMAIL_TEMPLATE_LOCALES` 才生效),其次才是
* > 部署默认(`localization.locale` → `i18n.defaultLocale`)。
*
* Asserted at the LOCALE named on the send, which is this layer's whole output.
* That a `zh-CN` row then renders a Chinese subject and no en-US text is
* `plugin-email/src/auth-templates-locales.test.ts`, which owns the row half;
* the two together are the card's acceptance criterion.
*
* The four sends driven here are the ones where the requester IS the recipient.
* The invitation is asserted to ABSTAIN in its own case below — better-auth
* hands it a request too, but it is the inviter's.
*/
async function driveWithHeader(
deploymentLocale: string | undefined,
header: string | undefined,
) {
const { capturedConfig, sent } = await boot(deploymentLocale);
const request =
header === undefined
? undefined
: new Request('http://x/any', { headers: { 'accept-language': header } });

// Measured better-auth 1.7.x shapes, NOT assumed: reset / verify / invitation
// receive `ctx.request`, magic-link receives the endpoint `ctx`, and the
// change-email notice fires from the global after-hook's `ctx`.
await capturedConfig.emailAndPassword.sendResetPassword(
{ user: USER, url: 'http://x/reset', token: 't' },
request,
);
await capturedConfig.emailVerification.sendVerificationEmail(
{ user: USER, url: 'http://x/verify', token: 't' },
request,
);

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' } },
},
request,
);

const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
await magic._opts.sendMagicLink(
{ email: 'ada@example.com', url: 'http://x/magic', token: 't' },
request ? { request } : undefined,
);

await capturedConfig.hooks.after({
path: '/change-email',
body: { newEmail: 'new@example.com' },
request,
context: {
__osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' },
returned: { status: true },
},
});

const byTemplate = (name: string) => sent.find((x: any) => x.template === name);
return {
sent,
/** The four sends whose recipient is the requester. */
requesterIsRecipient: [
'auth.password_reset',
'auth.verify_email',
'auth.magic_link',
'auth.email_change_notice',
].map((t) => byTemplate(t)!),
invitation: byTemplate('auth.invitation')!,
};
}

describe('#14319 — Accept-Language outranks the deployment default', () => {
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('a zh-CN caller gets zh-CN even though the deployment speaks English', async () => {
// The card's repro: Chinese browser, English deployment default. Before
// this ruling every one of these read `en-US`.
const { sent, requesterIsRecipient } = await driveWithHeader('en', 'zh-CN,zh;q=0.9,en;q=0.8');
// A send that never happened would make the locale assertion vacuous.
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) {
expect(input.locale, `${input.template} did not follow the request`).toBe('zh-CN');
}
});

it.each(['ja-JP', 'es-ES', 'en-US'])('and the same for a %s caller', async (tag) => {
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe(tag);
});

it('a caller who asked for nothing falls back to the deployment default', async () => {
const { sent, requesterIsRecipient } = await driveWithHeader('zh-CN', undefined);
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
});

it.each(['fr-FR', 'de', 'pt-BR', '*'])(
'a caller asking for %s — a locale we ship no auth row for — falls back to the deployment default',
async (tag) => {
// The ruling's "命中 AUTH_EMAIL_TEMPLATE_LOCALES 才生效" half. Honouring
// an unshipped tag would name a row that does not exist, which is the
// row-locale vs filter-locale split all over again.
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
},
);

it('with NO deployment default and an unshipped request, nothing is named at all', async () => {
// Both rungs silent ⇒ absent key, which is what EmailService's ladder
// contract ("no locale means the DOCUMENTED default") is written against.
const { requesterIsRecipient } = await driveWithHeader(undefined, 'fr-FR');
for (const input of requesterIsRecipient) {
expect(input.locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(input, 'locale')).toBe(false);
}
});

it('the INVITATION abstains — the request belongs to the inviter, not the invitee', async () => {
const { invitation } = await driveWithHeader('zh-CN', 'en-US');
// An English-speaking admin must not force English on their Chinese
// workspace's invitees; this send keeps the deployment rung.
expect(invitation.locale).toBe('zh-CN');
});

it('naming a request locale does not disturb the rest of the payload', async () => {
const { requesterIsRecipient } = await driveWithHeader('en', 'zh-CN');
const reset = requesterIsRecipient.find((x: any) => x.template === 'auth.password_reset')!;
expect(reset.data.resetUrl).toBe('http://x/reset');
expect(reset.relatedObject).toBe('sys_user');
expect(reset.relatedId).toBe('u1');
});
});

describe('#14319 — authEmailLocaleFromRequest', () => {
it('reads a Web Request and strips the quality weights', () => {
const req = new Request('http://x/', { headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' } });
expect(authEmailLocaleFromRequest(req)).toBe('zh-CN');
});

it('reads a better-auth endpoint ctx too — the shape sendMagicLink is handed', () => {
// Measured, not assumed: magic-link/index.mjs calls `sendMagicLink({...}, ctx)`.
const req = new Request('http://x/', { headers: { 'accept-language': 'ja-JP' } });
expect(authEmailLocaleFromRequest({ request: req })).toBe('ja-JP');
});

it('reads a plain header bag, either spelling', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'es-ES' } })).toBe('es-ES');
expect(authEmailLocaleFromRequest({ headers: { 'Accept-Language': 'es-ES' } })).toBe('es-ES');
});

it('promotes a bare language to the row we ship for it', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'zh' } })).toBe('zh-CN');
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en' } })).toBe('en-US');
});

it('refuses a locale we ship no auth row for, rather than naming a missing row', () => {
for (const tag of ['fr-FR', 'de', 'pt-BR', 'en-GB']) {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': tag } })).toBeUndefined();
}
});

it('treats an absent, empty or wildcard header as no preference', () => {
expect(authEmailLocaleFromRequest(undefined)).toBeUndefined();
expect(authEmailLocaleFromRequest(null)).toBeUndefined();
expect(authEmailLocaleFromRequest({})).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: {} })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '' } })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '*' } })).toBeUndefined();
});

it('never throws when the header bag itself is hostile', () => {
// A vendor changing the shape it hands a callback must degrade to the
// deployment default, never fail the send.
const hostile = { headers: { get() { throw new Error('boom'); } } };
expect(() => authEmailLocaleFromRequest(hostile)).not.toThrow();
expect(authEmailLocaleFromRequest(hostile)).toBeUndefined();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/auth-email-accept-language.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/plugin-auth": minor
---

feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319)

Request-triggered auth email — signup verification, password reset, magic link,
and the change-email notice — now picks its `sys_email_template` row from the
requesting caller's `Accept-Language`, falling back to the deployment default
(`localization.locale`, then `i18n.defaultLocale`) and finally to
`EmailService`'s documented `en-US`.

The motivating case is the one no deployment default can answer: at cloud
self-service signup there is no workspace yet, so nothing on the server
represents that person's language — a Chinese browser reached a Chinese signup
screen and received an English verification email.

The header is parsed by the platform's existing `preferredLocaleFromHeader`,
the same function REST uses for metadata translation and the runtime dispatcher
uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the
screen that triggered it. A requested locale takes effect only when it names one
of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything
else falls through rather than naming a row that does not exist.

Two deliberate exclusions. **Invitations keep the deployment default**:
better-auth hands that callback a request too, but it is the *inviter's*, and
stamping their browser language onto the invitee's mail would reproduce this
same defect one seat over. **Per-user language stays deferred** — `sys_user`
grows no locale column here.

This ships as `minor` because it changes which template row an existing
deployment sends: a workspace whose users' browsers ask for a different language
than the workspace declares will now send in the browser's language.

**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had
rejected `Accept-Language` outright. Both are recorded, with the older one
marked superseded, on `AuthManager.setDefaultEmailLocale`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:1345` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
| 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` |
Expand Down
213 changes: 208 additions & 5 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,24 +3,30 @@
/**
* #8195 — every auth email names the deployment-default locale.
*
* 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; no `sys_user.locale` column.
* Maintainer ruling 2026-09-02 (#14319), which SUPERSEDED the 2026-08-13 one
* this file was written against: a request-triggered auth email takes the
* 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. Still no `sys_user.locale` column —
* that half stayed deferred. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the
* request rung's own cases are the last describe block 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
* were unreachable through the platform's own send path — a zh-CN deployment
* received English credential mail while its UI spoke Chinese.
*
* This file owns the SENDING half: that all five sites name the locale, that
* This file owns the SENDING half: that all five sites name a locale, that
* an unconfigured deployment still names nothing, and that the catalog spelling
* (`en`) is mapped onto the row spelling (`en-US`). The template half — that a
* row actually exists in each locale and reads naturally — is
* `plugin-email/src/auth-templates-locales.test.ts`.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager, normalizeAuthEmailLocale } from './auth-manager';
import { AuthManager, normalizeAuthEmailLocale, authEmailLocaleFromRequest } from './auth-manager';

vi.mock('better-auth', () => ({
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
Expand DownExpand Up@@ -252,3 +258,200 @@ describe('#8195 — normalizeAuthEmailLocale', () => {
for (const input of sent) expect(input.locale).toBe('en-US');
});
});

// ── #14319 — the request rung ──────────────────────────────────────────────

/**
* Maintainer ruling 2026-09-02, quoted verbatim and untranslated:
*
* > 注册 / 登录 / 重置密码等由请求触发的 auth 邮件,语言优先取请求的
* > `Accept-Language`(命中 `AUTH_EMAIL_TEMPLATE_LOCALES` 才生效),其次才是
* > 部署默认(`localization.locale` → `i18n.defaultLocale`)。
*
* Asserted at the LOCALE named on the send, which is this layer's whole output.
* That a `zh-CN` row then renders a Chinese subject and no en-US text is
* `plugin-email/src/auth-templates-locales.test.ts`, which owns the row half;
* the two together are the card's acceptance criterion.
*
* The four sends driven here are the ones where the requester IS the recipient.
* The invitation is asserted to ABSTAIN in its own case below — better-auth
* hands it a request too, but it is the inviter's.
*/
async function driveWithHeader(
deploymentLocale: string | undefined,
header: string | undefined,
) {
const { capturedConfig, sent } = await boot(deploymentLocale);
const request =
header === undefined
? undefined
: new Request('http://x/any', { headers: { 'accept-language': header } });

// Measured better-auth 1.7.x shapes, NOT assumed: reset / verify / invitation
// receive `ctx.request`, magic-link receives the endpoint `ctx`, and the
// change-email notice fires from the global after-hook's `ctx`.
await capturedConfig.emailAndPassword.sendResetPassword(
{ user: USER, url: 'http://x/reset', token: 't' },
request,
);
await capturedConfig.emailVerification.sendVerificationEmail(
{ user: USER, url: 'http://x/verify', token: 't' },
request,
);

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' } },
},
request,
);

const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
await magic._opts.sendMagicLink(
{ email: 'ada@example.com', url: 'http://x/magic', token: 't' },
request ? { request } : undefined,
);

await capturedConfig.hooks.after({
path: '/change-email',
body: { newEmail: 'new@example.com' },
request,
context: {
__osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' },
returned: { status: true },
},
});

const byTemplate = (name: string) => sent.find((x: any) => x.template === name);
return {
sent,
/** The four sends whose recipient is the requester. */
requesterIsRecipient: [
'auth.password_reset',
'auth.verify_email',
'auth.magic_link',
'auth.email_change_notice',
].map((t) => byTemplate(t)!),
invitation: byTemplate('auth.invitation')!,
};
}

describe('#14319 — Accept-Language outranks the deployment default', () => {
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('a zh-CN caller gets zh-CN even though the deployment speaks English', async () => {
// The card's repro: Chinese browser, English deployment default. Before
// this ruling every one of these read `en-US`.
const { sent, requesterIsRecipient } = await driveWithHeader('en', 'zh-CN,zh;q=0.9,en;q=0.8');
// A send that never happened would make the locale assertion vacuous.
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) {
expect(input.locale, `${input.template} did not follow the request`).toBe('zh-CN');
}
});

it.each(['ja-JP', 'es-ES', 'en-US'])('and the same for a %s caller', async (tag) => {
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe(tag);
});

it('a caller who asked for nothing falls back to the deployment default', async () => {
const { sent, requesterIsRecipient } = await driveWithHeader('zh-CN', undefined);
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
});

it.each(['fr-FR', 'de', 'pt-BR', '*'])(
'a caller asking for %s — a locale we ship no auth row for — falls back to the deployment default',
async (tag) => {
// The ruling's "命中 AUTH_EMAIL_TEMPLATE_LOCALES 才生效" half. Honouring
// an unshipped tag would name a row that does not exist, which is the
// row-locale vs filter-locale split all over again.
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
},
);

it('with NO deployment default and an unshipped request, nothing is named at all', async () => {
// Both rungs silent ⇒ absent key, which is what EmailService's ladder
// contract ("no locale means the DOCUMENTED default") is written against.
const { requesterIsRecipient } = await driveWithHeader(undefined, 'fr-FR');
for (const input of requesterIsRecipient) {
expect(input.locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(input, 'locale')).toBe(false);
}
});

it('the INVITATION abstains — the request belongs to the inviter, not the invitee', async () => {
const { invitation } = await driveWithHeader('zh-CN', 'en-US');
// An English-speaking admin must not force English on their Chinese
// workspace's invitees; this send keeps the deployment rung.
expect(invitation.locale).toBe('zh-CN');
});

it('naming a request locale does not disturb the rest of the payload', async () => {
const { requesterIsRecipient } = await driveWithHeader('en', 'zh-CN');
const reset = requesterIsRecipient.find((x: any) => x.template === 'auth.password_reset')!;
expect(reset.data.resetUrl).toBe('http://x/reset');
expect(reset.relatedObject).toBe('sys_user');
expect(reset.relatedId).toBe('u1');
});
});

describe('#14319 — authEmailLocaleFromRequest', () => {
it('reads a Web Request and strips the quality weights', () => {
const req = new Request('http://x/', { headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' } });
expect(authEmailLocaleFromRequest(req)).toBe('zh-CN');
});

it('reads a better-auth endpoint ctx too — the shape sendMagicLink is handed', () => {
// Measured, not assumed: magic-link/index.mjs calls `sendMagicLink({...}, ctx)`.
const req = new Request('http://x/', { headers: { 'accept-language': 'ja-JP' } });
expect(authEmailLocaleFromRequest({ request: req })).toBe('ja-JP');
});

it('reads a plain header bag, either spelling', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'es-ES' } })).toBe('es-ES');
expect(authEmailLocaleFromRequest({ headers: { 'Accept-Language': 'es-ES' } })).toBe('es-ES');
});

it('promotes a bare language to the row we ship for it', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'zh' } })).toBe('zh-CN');
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en' } })).toBe('en-US');
});

it('refuses a locale we ship no auth row for, rather than naming a missing row', () => {
for (const tag of ['fr-FR', 'de', 'pt-BR', 'en-GB']) {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': tag } })).toBeUndefined();
}
});

it('treats an absent, empty or wildcard header as no preference', () => {
expect(authEmailLocaleFromRequest(undefined)).toBeUndefined();
expect(authEmailLocaleFromRequest(null)).toBeUndefined();
expect(authEmailLocaleFromRequest({})).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: {} })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '' } })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '*' } })).toBeUndefined();
});

it('never throws when the header bag itself is hostile', () => {
// A vendor changing the shape it hands a callback must degrade to the
// deployment default, never fail the send.
const hostile = { headers: { get() { throw new Error('boom'); } } };
expect(() => authEmailLocaleFromRequest(hostile)).not.toThrow();
expect(authEmailLocaleFromRequest(hostile)).toBeUndefined();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/auth-email-accept-language.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/plugin-auth": minor
---

feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319)

Request-triggered auth email — signup verification, password reset, magic link,
and the change-email notice — now picks its `sys_email_template` row from the
requesting caller's `Accept-Language`, falling back to the deployment default
(`localization.locale`, then `i18n.defaultLocale`) and finally to
`EmailService`'s documented `en-US`.

The motivating case is the one no deployment default can answer: at cloud
self-service signup there is no workspace yet, so nothing on the server
represents that person's language — a Chinese browser reached a Chinese signup
screen and received an English verification email.

The header is parsed by the platform's existing `preferredLocaleFromHeader`,
the same function REST uses for metadata translation and the runtime dispatcher
uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the
screen that triggered it. A requested locale takes effect only when it names one
of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything
else falls through rather than naming a row that does not exist.

Two deliberate exclusions. **Invitations keep the deployment default**:
better-auth hands that callback a request too, but it is the *inviter's*, and
stamping their browser language onto the invitee's mail would reproduce this
same defect one seat over. **Per-user language stays deferred** — `sys_user`
grows no locale column here.

This ships as `minor` because it changes which template row an existing
deployment sends: a workspace whose users' browsers ask for a different language
than the workspace declares will now send in the browser's language.

**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had
rejected `Accept-Language` outright. Both are recorded, with the older one
marked superseded, on `AuthManager.setDefaultEmailLocale`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:1345` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
| 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` |
Expand Down
213 changes: 208 additions & 5 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,24 +3,30 @@
/**
* #8195 — every auth email names the deployment-default locale.
*
* 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; no `sys_user.locale` column.
* Maintainer ruling 2026-09-02 (#14319), which SUPERSEDED the 2026-08-13 one
* this file was written against: a request-triggered auth email takes the
* 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. Still no `sys_user.locale` column —
* that half stayed deferred. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the
* request rung's own cases are the last describe block 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
* were unreachable through the platform's own send path — a zh-CN deployment
* received English credential mail while its UI spoke Chinese.
*
* This file owns the SENDING half: that all five sites name the locale, that
* This file owns the SENDING half: that all five sites name a locale, that
* an unconfigured deployment still names nothing, and that the catalog spelling
* (`en`) is mapped onto the row spelling (`en-US`). The template half — that a
* row actually exists in each locale and reads naturally — is
* `plugin-email/src/auth-templates-locales.test.ts`.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager, normalizeAuthEmailLocale } from './auth-manager';
import { AuthManager, normalizeAuthEmailLocale, authEmailLocaleFromRequest } from './auth-manager';

vi.mock('better-auth', () => ({
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
Expand DownExpand Up@@ -252,3 +258,200 @@ describe('#8195 — normalizeAuthEmailLocale', () => {
for (const input of sent) expect(input.locale).toBe('en-US');
});
});

// ── #14319 — the request rung ──────────────────────────────────────────────

/**
* Maintainer ruling 2026-09-02, quoted verbatim and untranslated:
*
* > 注册 / 登录 / 重置密码等由请求触发的 auth 邮件,语言优先取请求的
* > `Accept-Language`(命中 `AUTH_EMAIL_TEMPLATE_LOCALES` 才生效),其次才是
* > 部署默认(`localization.locale` → `i18n.defaultLocale`)。
*
* Asserted at the LOCALE named on the send, which is this layer's whole output.
* That a `zh-CN` row then renders a Chinese subject and no en-US text is
* `plugin-email/src/auth-templates-locales.test.ts`, which owns the row half;
* the two together are the card's acceptance criterion.
*
* The four sends driven here are the ones where the requester IS the recipient.
* The invitation is asserted to ABSTAIN in its own case below — better-auth
* hands it a request too, but it is the inviter's.
*/
async function driveWithHeader(
deploymentLocale: string | undefined,
header: string | undefined,
) {
const { capturedConfig, sent } = await boot(deploymentLocale);
const request =
header === undefined
? undefined
: new Request('http://x/any', { headers: { 'accept-language': header } });

// Measured better-auth 1.7.x shapes, NOT assumed: reset / verify / invitation
// receive `ctx.request`, magic-link receives the endpoint `ctx`, and the
// change-email notice fires from the global after-hook's `ctx`.
await capturedConfig.emailAndPassword.sendResetPassword(
{ user: USER, url: 'http://x/reset', token: 't' },
request,
);
await capturedConfig.emailVerification.sendVerificationEmail(
{ user: USER, url: 'http://x/verify', token: 't' },
request,
);

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' } },
},
request,
);

const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
await magic._opts.sendMagicLink(
{ email: 'ada@example.com', url: 'http://x/magic', token: 't' },
request ? { request } : undefined,
);

await capturedConfig.hooks.after({
path: '/change-email',
body: { newEmail: 'new@example.com' },
request,
context: {
__osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' },
returned: { status: true },
},
});

const byTemplate = (name: string) => sent.find((x: any) => x.template === name);
return {
sent,
/** The four sends whose recipient is the requester. */
requesterIsRecipient: [
'auth.password_reset',
'auth.verify_email',
'auth.magic_link',
'auth.email_change_notice',
].map((t) => byTemplate(t)!),
invitation: byTemplate('auth.invitation')!,
};
}

describe('#14319 — Accept-Language outranks the deployment default', () => {
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('a zh-CN caller gets zh-CN even though the deployment speaks English', async () => {
// The card's repro: Chinese browser, English deployment default. Before
// this ruling every one of these read `en-US`.
const { sent, requesterIsRecipient } = await driveWithHeader('en', 'zh-CN,zh;q=0.9,en;q=0.8');
// A send that never happened would make the locale assertion vacuous.
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) {
expect(input.locale, `${input.template} did not follow the request`).toBe('zh-CN');
}
});

it.each(['ja-JP', 'es-ES', 'en-US'])('and the same for a %s caller', async (tag) => {
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe(tag);
});

it('a caller who asked for nothing falls back to the deployment default', async () => {
const { sent, requesterIsRecipient } = await driveWithHeader('zh-CN', undefined);
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
});

it.each(['fr-FR', 'de', 'pt-BR', '*'])(
'a caller asking for %s — a locale we ship no auth row for — falls back to the deployment default',
async (tag) => {
// The ruling's "命中 AUTH_EMAIL_TEMPLATE_LOCALES 才生效" half. Honouring
// an unshipped tag would name a row that does not exist, which is the
// row-locale vs filter-locale split all over again.
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
},
);

it('with NO deployment default and an unshipped request, nothing is named at all', async () => {
// Both rungs silent ⇒ absent key, which is what EmailService's ladder
// contract ("no locale means the DOCUMENTED default") is written against.
const { requesterIsRecipient } = await driveWithHeader(undefined, 'fr-FR');
for (const input of requesterIsRecipient) {
expect(input.locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(input, 'locale')).toBe(false);
}
});

it('the INVITATION abstains — the request belongs to the inviter, not the invitee', async () => {
const { invitation } = await driveWithHeader('zh-CN', 'en-US');
// An English-speaking admin must not force English on their Chinese
// workspace's invitees; this send keeps the deployment rung.
expect(invitation.locale).toBe('zh-CN');
});

it('naming a request locale does not disturb the rest of the payload', async () => {
const { requesterIsRecipient } = await driveWithHeader('en', 'zh-CN');
const reset = requesterIsRecipient.find((x: any) => x.template === 'auth.password_reset')!;
expect(reset.data.resetUrl).toBe('http://x/reset');
expect(reset.relatedObject).toBe('sys_user');
expect(reset.relatedId).toBe('u1');
});
});

describe('#14319 — authEmailLocaleFromRequest', () => {
it('reads a Web Request and strips the quality weights', () => {
const req = new Request('http://x/', { headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' } });
expect(authEmailLocaleFromRequest(req)).toBe('zh-CN');
});

it('reads a better-auth endpoint ctx too — the shape sendMagicLink is handed', () => {
// Measured, not assumed: magic-link/index.mjs calls `sendMagicLink({...}, ctx)`.
const req = new Request('http://x/', { headers: { 'accept-language': 'ja-JP' } });
expect(authEmailLocaleFromRequest({ request: req })).toBe('ja-JP');
});

it('reads a plain header bag, either spelling', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'es-ES' } })).toBe('es-ES');
expect(authEmailLocaleFromRequest({ headers: { 'Accept-Language': 'es-ES' } })).toBe('es-ES');
});

it('promotes a bare language to the row we ship for it', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'zh' } })).toBe('zh-CN');
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en' } })).toBe('en-US');
});

it('refuses a locale we ship no auth row for, rather than naming a missing row', () => {
for (const tag of ['fr-FR', 'de', 'pt-BR', 'en-GB']) {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': tag } })).toBeUndefined();
}
});

it('treats an absent, empty or wildcard header as no preference', () => {
expect(authEmailLocaleFromRequest(undefined)).toBeUndefined();
expect(authEmailLocaleFromRequest(null)).toBeUndefined();
expect(authEmailLocaleFromRequest({})).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: {} })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '' } })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '*' } })).toBeUndefined();
});

it('never throws when the header bag itself is hostile', () => {
// A vendor changing the shape it hands a callback must degrade to the
// deployment default, never fail the send.
const hostile = { headers: { get() { throw new Error('boom'); } } };
expect(() => authEmailLocaleFromRequest(hostile)).not.toThrow();
expect(authEmailLocaleFromRequest(hostile)).toBeUndefined();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
37 changes: 37 additions & 0 deletions .changeset/auth-email-accept-language.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/plugin-auth": minor
---

feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319)

Request-triggered auth email — signup verification, password reset, magic link,
and the change-email notice — now picks its `sys_email_template` row from the
requesting caller's `Accept-Language`, falling back to the deployment default
(`localization.locale`, then `i18n.defaultLocale`) and finally to
`EmailService`'s documented `en-US`.

The motivating case is the one no deployment default can answer: at cloud
self-service signup there is no workspace yet, so nothing on the server
represents that person's language — a Chinese browser reached a Chinese signup
screen and received an English verification email.

The header is parsed by the platform's existing `preferredLocaleFromHeader`,
the same function REST uses for metadata translation and the runtime dispatcher
uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the
screen that triggered it. A requested locale takes effect only when it names one
of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything
else falls through rather than naming a row that does not exist.

Two deliberate exclusions. **Invitations keep the deployment default**:
better-auth hands that callback a request too, but it is the *inviter's*, and
stamping their browser language onto the invitee's mail would reproduce this
same defect one seat over. **Per-user language stays deferred** — `sys_user`
grows no locale column here.

This ships as `minor` because it changes which template row an existing
deployment sends: a workspace whose users' browsers ask for a different language
than the workspace declares will now send in the browser's language.

**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had
rejected `Accept-Language` outright. Both are recorded, with the older one
marked superseded, on `AuthManager.setDefaultEmailLocale`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:1345` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
| 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` |
Expand Down
213 changes: 208 additions & 5 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,24 +3,30 @@
/**
* #8195 — every auth email names the deployment-default locale.
*
* 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; no `sys_user.locale` column.
* Maintainer ruling 2026-09-02 (#14319), which SUPERSEDED the 2026-08-13 one
* this file was written against: a request-triggered auth email takes the
* 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. Still no `sys_user.locale` column —
* that half stayed deferred. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the
* request rung's own cases are the last describe block 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
* were unreachable through the platform's own send path — a zh-CN deployment
* received English credential mail while its UI spoke Chinese.
*
* This file owns the SENDING half: that all five sites name the locale, that
* This file owns the SENDING half: that all five sites name a locale, that
* an unconfigured deployment still names nothing, and that the catalog spelling
* (`en`) is mapped onto the row spelling (`en-US`). The template half — that a
* row actually exists in each locale and reads naturally — is
* `plugin-email/src/auth-templates-locales.test.ts`.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager, normalizeAuthEmailLocale } from './auth-manager';
import { AuthManager, normalizeAuthEmailLocale, authEmailLocaleFromRequest } from './auth-manager';

vi.mock('better-auth', () => ({
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
Expand DownExpand Up@@ -252,3 +258,200 @@ describe('#8195 — normalizeAuthEmailLocale', () => {
for (const input of sent) expect(input.locale).toBe('en-US');
});
});

// ── #14319 — the request rung ──────────────────────────────────────────────

/**
* Maintainer ruling 2026-09-02, quoted verbatim and untranslated:
*
* > 注册 / 登录 / 重置密码等由请求触发的 auth 邮件,语言优先取请求的
* > `Accept-Language`(命中 `AUTH_EMAIL_TEMPLATE_LOCALES` 才生效),其次才是
* > 部署默认(`localization.locale` → `i18n.defaultLocale`)。
*
* Asserted at the LOCALE named on the send, which is this layer's whole output.
* That a `zh-CN` row then renders a Chinese subject and no en-US text is
* `plugin-email/src/auth-templates-locales.test.ts`, which owns the row half;
* the two together are the card's acceptance criterion.
*
* The four sends driven here are the ones where the requester IS the recipient.
* The invitation is asserted to ABSTAIN in its own case below — better-auth
* hands it a request too, but it is the inviter's.
*/
async function driveWithHeader(
deploymentLocale: string | undefined,
header: string | undefined,
) {
const { capturedConfig, sent } = await boot(deploymentLocale);
const request =
header === undefined
? undefined
: new Request('http://x/any', { headers: { 'accept-language': header } });

// Measured better-auth 1.7.x shapes, NOT assumed: reset / verify / invitation
// receive `ctx.request`, magic-link receives the endpoint `ctx`, and the
// change-email notice fires from the global after-hook's `ctx`.
await capturedConfig.emailAndPassword.sendResetPassword(
{ user: USER, url: 'http://x/reset', token: 't' },
request,
);
await capturedConfig.emailVerification.sendVerificationEmail(
{ user: USER, url: 'http://x/verify', token: 't' },
request,
);

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' } },
},
request,
);

const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
await magic._opts.sendMagicLink(
{ email: 'ada@example.com', url: 'http://x/magic', token: 't' },
request ? { request } : undefined,
);

await capturedConfig.hooks.after({
path: '/change-email',
body: { newEmail: 'new@example.com' },
request,
context: {
__osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' },
returned: { status: true },
},
});

const byTemplate = (name: string) => sent.find((x: any) => x.template === name);
return {
sent,
/** The four sends whose recipient is the requester. */
requesterIsRecipient: [
'auth.password_reset',
'auth.verify_email',
'auth.magic_link',
'auth.email_change_notice',
].map((t) => byTemplate(t)!),
invitation: byTemplate('auth.invitation')!,
};
}

describe('#14319 — Accept-Language outranks the deployment default', () => {
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('a zh-CN caller gets zh-CN even though the deployment speaks English', async () => {
// The card's repro: Chinese browser, English deployment default. Before
// this ruling every one of these read `en-US`.
const { sent, requesterIsRecipient } = await driveWithHeader('en', 'zh-CN,zh;q=0.9,en;q=0.8');
// A send that never happened would make the locale assertion vacuous.
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) {
expect(input.locale, `${input.template} did not follow the request`).toBe('zh-CN');
}
});

it.each(['ja-JP', 'es-ES', 'en-US'])('and the same for a %s caller', async (tag) => {
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe(tag);
});

it('a caller who asked for nothing falls back to the deployment default', async () => {
const { sent, requesterIsRecipient } = await driveWithHeader('zh-CN', undefined);
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
});

it.each(['fr-FR', 'de', 'pt-BR', '*'])(
'a caller asking for %s — a locale we ship no auth row for — falls back to the deployment default',
async (tag) => {
// The ruling's "命中 AUTH_EMAIL_TEMPLATE_LOCALES 才生效" half. Honouring
// an unshipped tag would name a row that does not exist, which is the
// row-locale vs filter-locale split all over again.
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
},
);

it('with NO deployment default and an unshipped request, nothing is named at all', async () => {
// Both rungs silent ⇒ absent key, which is what EmailService's ladder
// contract ("no locale means the DOCUMENTED default") is written against.
const { requesterIsRecipient } = await driveWithHeader(undefined, 'fr-FR');
for (const input of requesterIsRecipient) {
expect(input.locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(input, 'locale')).toBe(false);
}
});

it('the INVITATION abstains — the request belongs to the inviter, not the invitee', async () => {
const { invitation } = await driveWithHeader('zh-CN', 'en-US');
// An English-speaking admin must not force English on their Chinese
// workspace's invitees; this send keeps the deployment rung.
expect(invitation.locale).toBe('zh-CN');
});

it('naming a request locale does not disturb the rest of the payload', async () => {
const { requesterIsRecipient } = await driveWithHeader('en', 'zh-CN');
const reset = requesterIsRecipient.find((x: any) => x.template === 'auth.password_reset')!;
expect(reset.data.resetUrl).toBe('http://x/reset');
expect(reset.relatedObject).toBe('sys_user');
expect(reset.relatedId).toBe('u1');
});
});

describe('#14319 — authEmailLocaleFromRequest', () => {
it('reads a Web Request and strips the quality weights', () => {
const req = new Request('http://x/', { headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' } });
expect(authEmailLocaleFromRequest(req)).toBe('zh-CN');
});

it('reads a better-auth endpoint ctx too — the shape sendMagicLink is handed', () => {
// Measured, not assumed: magic-link/index.mjs calls `sendMagicLink({...}, ctx)`.
const req = new Request('http://x/', { headers: { 'accept-language': 'ja-JP' } });
expect(authEmailLocaleFromRequest({ request: req })).toBe('ja-JP');
});

it('reads a plain header bag, either spelling', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'es-ES' } })).toBe('es-ES');
expect(authEmailLocaleFromRequest({ headers: { 'Accept-Language': 'es-ES' } })).toBe('es-ES');
});

it('promotes a bare language to the row we ship for it', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'zh' } })).toBe('zh-CN');
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en' } })).toBe('en-US');
});

it('refuses a locale we ship no auth row for, rather than naming a missing row', () => {
for (const tag of ['fr-FR', 'de', 'pt-BR', 'en-GB']) {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': tag } })).toBeUndefined();
}
});

it('treats an absent, empty or wildcard header as no preference', () => {
expect(authEmailLocaleFromRequest(undefined)).toBeUndefined();
expect(authEmailLocaleFromRequest(null)).toBeUndefined();
expect(authEmailLocaleFromRequest({})).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: {} })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '' } })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '*' } })).toBeUndefined();
});

it('never throws when the header bag itself is hostile', () => {
// A vendor changing the shape it hands a callback must degrade to the
// deployment default, never fail the send.
const hostile = { headers: { get() { throw new Error('boom'); } } };
expect(() => authEmailLocaleFromRequest(hostile)).not.toThrow();
expect(authEmailLocaleFromRequest(hostile)).toBeUndefined();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/auth-email-accept-language.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/plugin-auth": minor
---

feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319)

Request-triggered auth email — signup verification, password reset, magic link,
and the change-email notice — now picks its `sys_email_template` row from the
requesting caller's `Accept-Language`, falling back to the deployment default
(`localization.locale`, then `i18n.defaultLocale`) and finally to
`EmailService`'s documented `en-US`.

The motivating case is the one no deployment default can answer: at cloud
self-service signup there is no workspace yet, so nothing on the server
represents that person's language — a Chinese browser reached a Chinese signup
screen and received an English verification email.

The header is parsed by the platform's existing `preferredLocaleFromHeader`,
the same function REST uses for metadata translation and the runtime dispatcher
uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the
screen that triggered it. A requested locale takes effect only when it names one
of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything
else falls through rather than naming a row that does not exist.

Two deliberate exclusions. **Invitations keep the deployment default**:
better-auth hands that callback a request too, but it is the *inviter's*, and
stamping their browser language onto the invitee's mail would reproduce this
same defect one seat over. **Per-user language stays deferred** — `sys_user`
grows no locale column here.

This ships as `minor` because it changes which template row an existing
deployment sends: a workspace whose users' browsers ask for a different language
than the workspace declares will now send in the browser's language.

**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had
rejected `Accept-Language` outright. Both are recorded, with the older one
marked superseded, on `AuthManager.setDefaultEmailLocale`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:1345` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
| 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` |
Expand Down
213 changes: 208 additions & 5 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,24 +3,30 @@
/**
* #8195 — every auth email names the deployment-default locale.
*
* 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; no `sys_user.locale` column.
* Maintainer ruling 2026-09-02 (#14319), which SUPERSEDED the 2026-08-13 one
* this file was written against: a request-triggered auth email takes the
* 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. Still no `sys_user.locale` column —
* that half stayed deferred. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the
* request rung's own cases are the last describe block 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
* were unreachable through the platform's own send path — a zh-CN deployment
* received English credential mail while its UI spoke Chinese.
*
* This file owns the SENDING half: that all five sites name the locale, that
* This file owns the SENDING half: that all five sites name a locale, that
* an unconfigured deployment still names nothing, and that the catalog spelling
* (`en`) is mapped onto the row spelling (`en-US`). The template half — that a
* row actually exists in each locale and reads naturally — is
* `plugin-email/src/auth-templates-locales.test.ts`.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager, normalizeAuthEmailLocale } from './auth-manager';
import { AuthManager, normalizeAuthEmailLocale, authEmailLocaleFromRequest } from './auth-manager';

vi.mock('better-auth', () => ({
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
Expand DownExpand Up@@ -252,3 +258,200 @@ describe('#8195 — normalizeAuthEmailLocale', () => {
for (const input of sent) expect(input.locale).toBe('en-US');
});
});

// ── #14319 — the request rung ──────────────────────────────────────────────

/**
* Maintainer ruling 2026-09-02, quoted verbatim and untranslated:
*
* > 注册 / 登录 / 重置密码等由请求触发的 auth 邮件,语言优先取请求的
* > `Accept-Language`(命中 `AUTH_EMAIL_TEMPLATE_LOCALES` 才生效),其次才是
* > 部署默认(`localization.locale` → `i18n.defaultLocale`)。
*
* Asserted at the LOCALE named on the send, which is this layer's whole output.
* That a `zh-CN` row then renders a Chinese subject and no en-US text is
* `plugin-email/src/auth-templates-locales.test.ts`, which owns the row half;
* the two together are the card's acceptance criterion.
*
* The four sends driven here are the ones where the requester IS the recipient.
* The invitation is asserted to ABSTAIN in its own case below — better-auth
* hands it a request too, but it is the inviter's.
*/
async function driveWithHeader(
deploymentLocale: string | undefined,
header: string | undefined,
) {
const { capturedConfig, sent } = await boot(deploymentLocale);
const request =
header === undefined
? undefined
: new Request('http://x/any', { headers: { 'accept-language': header } });

// Measured better-auth 1.7.x shapes, NOT assumed: reset / verify / invitation
// receive `ctx.request`, magic-link receives the endpoint `ctx`, and the
// change-email notice fires from the global after-hook's `ctx`.
await capturedConfig.emailAndPassword.sendResetPassword(
{ user: USER, url: 'http://x/reset', token: 't' },
request,
);
await capturedConfig.emailVerification.sendVerificationEmail(
{ user: USER, url: 'http://x/verify', token: 't' },
request,
);

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' } },
},
request,
);

const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
await magic._opts.sendMagicLink(
{ email: 'ada@example.com', url: 'http://x/magic', token: 't' },
request ? { request } : undefined,
);

await capturedConfig.hooks.after({
path: '/change-email',
body: { newEmail: 'new@example.com' },
request,
context: {
__osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' },
returned: { status: true },
},
});

const byTemplate = (name: string) => sent.find((x: any) => x.template === name);
return {
sent,
/** The four sends whose recipient is the requester. */
requesterIsRecipient: [
'auth.password_reset',
'auth.verify_email',
'auth.magic_link',
'auth.email_change_notice',
].map((t) => byTemplate(t)!),
invitation: byTemplate('auth.invitation')!,
};
}

describe('#14319 — Accept-Language outranks the deployment default', () => {
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('a zh-CN caller gets zh-CN even though the deployment speaks English', async () => {
// The card's repro: Chinese browser, English deployment default. Before
// this ruling every one of these read `en-US`.
const { sent, requesterIsRecipient } = await driveWithHeader('en', 'zh-CN,zh;q=0.9,en;q=0.8');
// A send that never happened would make the locale assertion vacuous.
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) {
expect(input.locale, `${input.template} did not follow the request`).toBe('zh-CN');
}
});

it.each(['ja-JP', 'es-ES', 'en-US'])('and the same for a %s caller', async (tag) => {
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe(tag);
});

it('a caller who asked for nothing falls back to the deployment default', async () => {
const { sent, requesterIsRecipient } = await driveWithHeader('zh-CN', undefined);
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
});

it.each(['fr-FR', 'de', 'pt-BR', '*'])(
'a caller asking for %s — a locale we ship no auth row for — falls back to the deployment default',
async (tag) => {
// The ruling's "命中 AUTH_EMAIL_TEMPLATE_LOCALES 才生效" half. Honouring
// an unshipped tag would name a row that does not exist, which is the
// row-locale vs filter-locale split all over again.
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
},
);

it('with NO deployment default and an unshipped request, nothing is named at all', async () => {
// Both rungs silent ⇒ absent key, which is what EmailService's ladder
// contract ("no locale means the DOCUMENTED default") is written against.
const { requesterIsRecipient } = await driveWithHeader(undefined, 'fr-FR');
for (const input of requesterIsRecipient) {
expect(input.locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(input, 'locale')).toBe(false);
}
});

it('the INVITATION abstains — the request belongs to the inviter, not the invitee', async () => {
const { invitation } = await driveWithHeader('zh-CN', 'en-US');
// An English-speaking admin must not force English on their Chinese
// workspace's invitees; this send keeps the deployment rung.
expect(invitation.locale).toBe('zh-CN');
});

it('naming a request locale does not disturb the rest of the payload', async () => {
const { requesterIsRecipient } = await driveWithHeader('en', 'zh-CN');
const reset = requesterIsRecipient.find((x: any) => x.template === 'auth.password_reset')!;
expect(reset.data.resetUrl).toBe('http://x/reset');
expect(reset.relatedObject).toBe('sys_user');
expect(reset.relatedId).toBe('u1');
});
});

describe('#14319 — authEmailLocaleFromRequest', () => {
it('reads a Web Request and strips the quality weights', () => {
const req = new Request('http://x/', { headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' } });
expect(authEmailLocaleFromRequest(req)).toBe('zh-CN');
});

it('reads a better-auth endpoint ctx too — the shape sendMagicLink is handed', () => {
// Measured, not assumed: magic-link/index.mjs calls `sendMagicLink({...}, ctx)`.
const req = new Request('http://x/', { headers: { 'accept-language': 'ja-JP' } });
expect(authEmailLocaleFromRequest({ request: req })).toBe('ja-JP');
});

it('reads a plain header bag, either spelling', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'es-ES' } })).toBe('es-ES');
expect(authEmailLocaleFromRequest({ headers: { 'Accept-Language': 'es-ES' } })).toBe('es-ES');
});

it('promotes a bare language to the row we ship for it', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'zh' } })).toBe('zh-CN');
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en' } })).toBe('en-US');
});

it('refuses a locale we ship no auth row for, rather than naming a missing row', () => {
for (const tag of ['fr-FR', 'de', 'pt-BR', 'en-GB']) {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': tag } })).toBeUndefined();
}
});

it('treats an absent, empty or wildcard header as no preference', () => {
expect(authEmailLocaleFromRequest(undefined)).toBeUndefined();
expect(authEmailLocaleFromRequest(null)).toBeUndefined();
expect(authEmailLocaleFromRequest({})).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: {} })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '' } })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '*' } })).toBeUndefined();
});

it('never throws when the header bag itself is hostile', () => {
// A vendor changing the shape it hands a callback must degrade to the
// deployment default, never fail the send.
const hostile = { headers: { get() { throw new Error('boom'); } } };
expect(() => authEmailLocaleFromRequest(hostile)).not.toThrow();
expect(authEmailLocaleFromRequest(hostile)).toBeUndefined();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/auth-email-accept-language.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/plugin-auth": minor
---

feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319)

Request-triggered auth email — signup verification, password reset, magic link,
and the change-email notice — now picks its `sys_email_template` row from the
requesting caller's `Accept-Language`, falling back to the deployment default
(`localization.locale`, then `i18n.defaultLocale`) and finally to
`EmailService`'s documented `en-US`.

The motivating case is the one no deployment default can answer: at cloud
self-service signup there is no workspace yet, so nothing on the server
represents that person's language — a Chinese browser reached a Chinese signup
screen and received an English verification email.

The header is parsed by the platform's existing `preferredLocaleFromHeader`,
the same function REST uses for metadata translation and the runtime dispatcher
uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the
screen that triggered it. A requested locale takes effect only when it names one
of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything
else falls through rather than naming a row that does not exist.

Two deliberate exclusions. **Invitations keep the deployment default**:
better-auth hands that callback a request too, but it is the *inviter's*, and
stamping their browser language onto the invitee's mail would reproduce this
same defect one seat over. **Per-user language stays deferred** — `sys_user`
grows no locale column here.

This ships as `minor` because it changes which template row an existing
deployment sends: a workspace whose users' browsers ask for a different language
than the workspace declares will now send in the browser's language.

**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had
rejected `Accept-Language` outright. Both are recorded, with the older one
marked superseded, on `AuthManager.setDefaultEmailLocale`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:1345` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
| 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` |
Expand Down
213 changes: 208 additions & 5 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,24 +3,30 @@
/**
* #8195 — every auth email names the deployment-default locale.
*
* 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; no `sys_user.locale` column.
* Maintainer ruling 2026-09-02 (#14319), which SUPERSEDED the 2026-08-13 one
* this file was written against: a request-triggered auth email takes the
* 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. Still no `sys_user.locale` column —
* that half stayed deferred. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the
* request rung's own cases are the last describe block 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
* were unreachable through the platform's own send path — a zh-CN deployment
* received English credential mail while its UI spoke Chinese.
*
* This file owns the SENDING half: that all five sites name the locale, that
* This file owns the SENDING half: that all five sites name a locale, that
* an unconfigured deployment still names nothing, and that the catalog spelling
* (`en`) is mapped onto the row spelling (`en-US`). The template half — that a
* row actually exists in each locale and reads naturally — is
* `plugin-email/src/auth-templates-locales.test.ts`.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager, normalizeAuthEmailLocale } from './auth-manager';
import { AuthManager, normalizeAuthEmailLocale, authEmailLocaleFromRequest } from './auth-manager';

vi.mock('better-auth', () => ({
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
Expand DownExpand Up@@ -252,3 +258,200 @@ describe('#8195 — normalizeAuthEmailLocale', () => {
for (const input of sent) expect(input.locale).toBe('en-US');
});
});

// ── #14319 — the request rung ──────────────────────────────────────────────

/**
* Maintainer ruling 2026-09-02, quoted verbatim and untranslated:
*
* > 注册 / 登录 / 重置密码等由请求触发的 auth 邮件,语言优先取请求的
* > `Accept-Language`(命中 `AUTH_EMAIL_TEMPLATE_LOCALES` 才生效),其次才是
* > 部署默认(`localization.locale` → `i18n.defaultLocale`)。
*
* Asserted at the LOCALE named on the send, which is this layer's whole output.
* That a `zh-CN` row then renders a Chinese subject and no en-US text is
* `plugin-email/src/auth-templates-locales.test.ts`, which owns the row half;
* the two together are the card's acceptance criterion.
*
* The four sends driven here are the ones where the requester IS the recipient.
* The invitation is asserted to ABSTAIN in its own case below — better-auth
* hands it a request too, but it is the inviter's.
*/
async function driveWithHeader(
deploymentLocale: string | undefined,
header: string | undefined,
) {
const { capturedConfig, sent } = await boot(deploymentLocale);
const request =
header === undefined
? undefined
: new Request('http://x/any', { headers: { 'accept-language': header } });

// Measured better-auth 1.7.x shapes, NOT assumed: reset / verify / invitation
// receive `ctx.request`, magic-link receives the endpoint `ctx`, and the
// change-email notice fires from the global after-hook's `ctx`.
await capturedConfig.emailAndPassword.sendResetPassword(
{ user: USER, url: 'http://x/reset', token: 't' },
request,
);
await capturedConfig.emailVerification.sendVerificationEmail(
{ user: USER, url: 'http://x/verify', token: 't' },
request,
);

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' } },
},
request,
);

const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
await magic._opts.sendMagicLink(
{ email: 'ada@example.com', url: 'http://x/magic', token: 't' },
request ? { request } : undefined,
);

await capturedConfig.hooks.after({
path: '/change-email',
body: { newEmail: 'new@example.com' },
request,
context: {
__osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' },
returned: { status: true },
},
});

const byTemplate = (name: string) => sent.find((x: any) => x.template === name);
return {
sent,
/** The four sends whose recipient is the requester. */
requesterIsRecipient: [
'auth.password_reset',
'auth.verify_email',
'auth.magic_link',
'auth.email_change_notice',
].map((t) => byTemplate(t)!),
invitation: byTemplate('auth.invitation')!,
};
}

describe('#14319 — Accept-Language outranks the deployment default', () => {
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('a zh-CN caller gets zh-CN even though the deployment speaks English', async () => {
// The card's repro: Chinese browser, English deployment default. Before
// this ruling every one of these read `en-US`.
const { sent, requesterIsRecipient } = await driveWithHeader('en', 'zh-CN,zh;q=0.9,en;q=0.8');
// A send that never happened would make the locale assertion vacuous.
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) {
expect(input.locale, `${input.template} did not follow the request`).toBe('zh-CN');
}
});

it.each(['ja-JP', 'es-ES', 'en-US'])('and the same for a %s caller', async (tag) => {
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe(tag);
});

it('a caller who asked for nothing falls back to the deployment default', async () => {
const { sent, requesterIsRecipient } = await driveWithHeader('zh-CN', undefined);
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
});

it.each(['fr-FR', 'de', 'pt-BR', '*'])(
'a caller asking for %s — a locale we ship no auth row for — falls back to the deployment default',
async (tag) => {
// The ruling's "命中 AUTH_EMAIL_TEMPLATE_LOCALES 才生效" half. Honouring
// an unshipped tag would name a row that does not exist, which is the
// row-locale vs filter-locale split all over again.
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
},
);

it('with NO deployment default and an unshipped request, nothing is named at all', async () => {
// Both rungs silent ⇒ absent key, which is what EmailService's ladder
// contract ("no locale means the DOCUMENTED default") is written against.
const { requesterIsRecipient } = await driveWithHeader(undefined, 'fr-FR');
for (const input of requesterIsRecipient) {
expect(input.locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(input, 'locale')).toBe(false);
}
});

it('the INVITATION abstains — the request belongs to the inviter, not the invitee', async () => {
const { invitation } = await driveWithHeader('zh-CN', 'en-US');
// An English-speaking admin must not force English on their Chinese
// workspace's invitees; this send keeps the deployment rung.
expect(invitation.locale).toBe('zh-CN');
});

it('naming a request locale does not disturb the rest of the payload', async () => {
const { requesterIsRecipient } = await driveWithHeader('en', 'zh-CN');
const reset = requesterIsRecipient.find((x: any) => x.template === 'auth.password_reset')!;
expect(reset.data.resetUrl).toBe('http://x/reset');
expect(reset.relatedObject).toBe('sys_user');
expect(reset.relatedId).toBe('u1');
});
});

describe('#14319 — authEmailLocaleFromRequest', () => {
it('reads a Web Request and strips the quality weights', () => {
const req = new Request('http://x/', { headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' } });
expect(authEmailLocaleFromRequest(req)).toBe('zh-CN');
});

it('reads a better-auth endpoint ctx too — the shape sendMagicLink is handed', () => {
// Measured, not assumed: magic-link/index.mjs calls `sendMagicLink({...}, ctx)`.
const req = new Request('http://x/', { headers: { 'accept-language': 'ja-JP' } });
expect(authEmailLocaleFromRequest({ request: req })).toBe('ja-JP');
});

it('reads a plain header bag, either spelling', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'es-ES' } })).toBe('es-ES');
expect(authEmailLocaleFromRequest({ headers: { 'Accept-Language': 'es-ES' } })).toBe('es-ES');
});

it('promotes a bare language to the row we ship for it', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'zh' } })).toBe('zh-CN');
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en' } })).toBe('en-US');
});

it('refuses a locale we ship no auth row for, rather than naming a missing row', () => {
for (const tag of ['fr-FR', 'de', 'pt-BR', 'en-GB']) {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': tag } })).toBeUndefined();
}
});

it('treats an absent, empty or wildcard header as no preference', () => {
expect(authEmailLocaleFromRequest(undefined)).toBeUndefined();
expect(authEmailLocaleFromRequest(null)).toBeUndefined();
expect(authEmailLocaleFromRequest({})).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: {} })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '' } })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '*' } })).toBeUndefined();
});

it('never throws when the header bag itself is hostile', () => {
// A vendor changing the shape it hands a callback must degrade to the
// deployment default, never fail the send.
const hostile = { headers: { get() { throw new Error('boom'); } } };
expect(() => authEmailLocaleFromRequest(hostile)).not.toThrow();
expect(authEmailLocaleFromRequest(hostile)).toBeUndefined();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
37 changes: 37 additions & 0 deletions .changeset/auth-email-accept-language.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/plugin-auth": minor
---

feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319)

Request-triggered auth email — signup verification, password reset, magic link,
and the change-email notice — now picks its `sys_email_template` row from the
requesting caller's `Accept-Language`, falling back to the deployment default
(`localization.locale`, then `i18n.defaultLocale`) and finally to
`EmailService`'s documented `en-US`.

The motivating case is the one no deployment default can answer: at cloud
self-service signup there is no workspace yet, so nothing on the server
represents that person's language — a Chinese browser reached a Chinese signup
screen and received an English verification email.

The header is parsed by the platform's existing `preferredLocaleFromHeader`,
the same function REST uses for metadata translation and the runtime dispatcher
uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the
screen that triggered it. A requested locale takes effect only when it names one
of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything
else falls through rather than naming a row that does not exist.

Two deliberate exclusions. **Invitations keep the deployment default**:
better-auth hands that callback a request too, but it is the *inviter's*, and
stamping their browser language onto the invitee's mail would reproduce this
same defect one seat over. **Per-user language stays deferred** — `sys_user`
grows no locale column here.

This ships as `minor` because it changes which template row an existing
deployment sends: a workspace whose users' browsers ask for a different language
than the workspace declares will now send in the browser's language.

**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had
rejected `Accept-Language` outright. Both are recorded, with the older one
marked superseded, on `AuthManager.setDefaultEmailLocale`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:1345` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
| 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` |
Expand Down
213 changes: 208 additions & 5 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,24 +3,30 @@
/**
* #8195 — every auth email names the deployment-default locale.
*
* 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; no `sys_user.locale` column.
* Maintainer ruling 2026-09-02 (#14319), which SUPERSEDED the 2026-08-13 one
* this file was written against: a request-triggered auth email takes the
* 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. Still no `sys_user.locale` column —
* that half stayed deferred. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest`; the
* request rung's own cases are the last describe block 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
* were unreachable through the platform's own send path — a zh-CN deployment
* received English credential mail while its UI spoke Chinese.
*
* This file owns the SENDING half: that all five sites name the locale, that
* This file owns the SENDING half: that all five sites name a locale, that
* an unconfigured deployment still names nothing, and that the catalog spelling
* (`en`) is mapped onto the row spelling (`en-US`). The template half — that a
* row actually exists in each locale and reads naturally — is
* `plugin-email/src/auth-templates-locales.test.ts`.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager, normalizeAuthEmailLocale } from './auth-manager';
import { AuthManager, normalizeAuthEmailLocale, authEmailLocaleFromRequest } from './auth-manager';

vi.mock('better-auth', () => ({
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
Expand DownExpand Up@@ -252,3 +258,200 @@ describe('#8195 — normalizeAuthEmailLocale', () => {
for (const input of sent) expect(input.locale).toBe('en-US');
});
});

// ── #14319 — the request rung ──────────────────────────────────────────────

/**
* Maintainer ruling 2026-09-02, quoted verbatim and untranslated:
*
* > 注册 / 登录 / 重置密码等由请求触发的 auth 邮件,语言优先取请求的
* > `Accept-Language`(命中 `AUTH_EMAIL_TEMPLATE_LOCALES` 才生效),其次才是
* > 部署默认(`localization.locale` → `i18n.defaultLocale`)。
*
* Asserted at the LOCALE named on the send, which is this layer's whole output.
* That a `zh-CN` row then renders a Chinese subject and no en-US text is
* `plugin-email/src/auth-templates-locales.test.ts`, which owns the row half;
* the two together are the card's acceptance criterion.
*
* The four sends driven here are the ones where the requester IS the recipient.
* The invitation is asserted to ABSTAIN in its own case below — better-auth
* hands it a request too, but it is the inviter's.
*/
async function driveWithHeader(
deploymentLocale: string | undefined,
header: string | undefined,
) {
const { capturedConfig, sent } = await boot(deploymentLocale);
const request =
header === undefined
? undefined
: new Request('http://x/any', { headers: { 'accept-language': header } });

// Measured better-auth 1.7.x shapes, NOT assumed: reset / verify / invitation
// receive `ctx.request`, magic-link receives the endpoint `ctx`, and the
// change-email notice fires from the global after-hook's `ctx`.
await capturedConfig.emailAndPassword.sendResetPassword(
{ user: USER, url: 'http://x/reset', token: 't' },
request,
);
await capturedConfig.emailVerification.sendVerificationEmail(
{ user: USER, url: 'http://x/verify', token: 't' },
request,
);

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' } },
},
request,
);

const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
await magic._opts.sendMagicLink(
{ email: 'ada@example.com', url: 'http://x/magic', token: 't' },
request ? { request } : undefined,
);

await capturedConfig.hooks.after({
path: '/change-email',
body: { newEmail: 'new@example.com' },
request,
context: {
__osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' },
returned: { status: true },
},
});

const byTemplate = (name: string) => sent.find((x: any) => x.template === name);
return {
sent,
/** The four sends whose recipient is the requester. */
requesterIsRecipient: [
'auth.password_reset',
'auth.verify_email',
'auth.magic_link',
'auth.email_change_notice',
].map((t) => byTemplate(t)!),
invitation: byTemplate('auth.invitation')!,
};
}

describe('#14319 — Accept-Language outranks the deployment default', () => {
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('a zh-CN caller gets zh-CN even though the deployment speaks English', async () => {
// The card's repro: Chinese browser, English deployment default. Before
// this ruling every one of these read `en-US`.
const { sent, requesterIsRecipient } = await driveWithHeader('en', 'zh-CN,zh;q=0.9,en;q=0.8');
// A send that never happened would make the locale assertion vacuous.
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) {
expect(input.locale, `${input.template} did not follow the request`).toBe('zh-CN');
}
});

it.each(['ja-JP', 'es-ES', 'en-US'])('and the same for a %s caller', async (tag) => {
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe(tag);
});

it('a caller who asked for nothing falls back to the deployment default', async () => {
const { sent, requesterIsRecipient } = await driveWithHeader('zh-CN', undefined);
expect(sent).toHaveLength(5);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
});

it.each(['fr-FR', 'de', 'pt-BR', '*'])(
'a caller asking for %s — a locale we ship no auth row for — falls back to the deployment default',
async (tag) => {
// The ruling's "命中 AUTH_EMAIL_TEMPLATE_LOCALES 才生效" half. Honouring
// an unshipped tag would name a row that does not exist, which is the
// row-locale vs filter-locale split all over again.
const { requesterIsRecipient } = await driveWithHeader('zh-CN', tag);
for (const input of requesterIsRecipient) expect(input.locale).toBe('zh-CN');
},
);

it('with NO deployment default and an unshipped request, nothing is named at all', async () => {
// Both rungs silent ⇒ absent key, which is what EmailService's ladder
// contract ("no locale means the DOCUMENTED default") is written against.
const { requesterIsRecipient } = await driveWithHeader(undefined, 'fr-FR');
for (const input of requesterIsRecipient) {
expect(input.locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(input, 'locale')).toBe(false);
}
});

it('the INVITATION abstains — the request belongs to the inviter, not the invitee', async () => {
const { invitation } = await driveWithHeader('zh-CN', 'en-US');
// An English-speaking admin must not force English on their Chinese
// workspace's invitees; this send keeps the deployment rung.
expect(invitation.locale).toBe('zh-CN');
});

it('naming a request locale does not disturb the rest of the payload', async () => {
const { requesterIsRecipient } = await driveWithHeader('en', 'zh-CN');
const reset = requesterIsRecipient.find((x: any) => x.template === 'auth.password_reset')!;
expect(reset.data.resetUrl).toBe('http://x/reset');
expect(reset.relatedObject).toBe('sys_user');
expect(reset.relatedId).toBe('u1');
});
});

describe('#14319 — authEmailLocaleFromRequest', () => {
it('reads a Web Request and strips the quality weights', () => {
const req = new Request('http://x/', { headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' } });
expect(authEmailLocaleFromRequest(req)).toBe('zh-CN');
});

it('reads a better-auth endpoint ctx too — the shape sendMagicLink is handed', () => {
// Measured, not assumed: magic-link/index.mjs calls `sendMagicLink({...}, ctx)`.
const req = new Request('http://x/', { headers: { 'accept-language': 'ja-JP' } });
expect(authEmailLocaleFromRequest({ request: req })).toBe('ja-JP');
});

it('reads a plain header bag, either spelling', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'es-ES' } })).toBe('es-ES');
expect(authEmailLocaleFromRequest({ headers: { 'Accept-Language': 'es-ES' } })).toBe('es-ES');
});

it('promotes a bare language to the row we ship for it', () => {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'zh' } })).toBe('zh-CN');
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': 'en' } })).toBe('en-US');
});

it('refuses a locale we ship no auth row for, rather than naming a missing row', () => {
for (const tag of ['fr-FR', 'de', 'pt-BR', 'en-GB']) {
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': tag } })).toBeUndefined();
}
});

it('treats an absent, empty or wildcard header as no preference', () => {
expect(authEmailLocaleFromRequest(undefined)).toBeUndefined();
expect(authEmailLocaleFromRequest(null)).toBeUndefined();
expect(authEmailLocaleFromRequest({})).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: {} })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '' } })).toBeUndefined();
expect(authEmailLocaleFromRequest({ headers: { 'accept-language': '*' } })).toBeUndefined();
});

it('never throws when the header bag itself is hostile', () => {
// A vendor changing the shape it hands a callback must degrade to the
// deployment default, never fail the send.
const hostile = { headers: { get() { throw new Error('boom'); } } };
expect(() => authEmailLocaleFromRequest(hostile)).not.toThrow();
expect(authEmailLocaleFromRequest(hostile)).toBeUndefined();
});
});
Loading
Loading