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
64 changes: 64 additions & 0 deletions .changeset/invitation-invitee-stored-locale.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/plugin-auth": patch
---

fix(auth): an invitation is written in the invitee's own `sys_user.locale` when the address already holds a row, and keeps the deployment default when it does not (#14641)

The four auth sends whose requester IS the recipient gained a per-recipient
language rung in #14762 (`sys_user.locale`, ruled on #13881). The two
**invitation** sends did not, and the recorded reason was structural rather
than an oversight: an invitee generally has no `sys_user` row until they accept,
so there is no stored language to read, and the *inviter's* `Accept-Language` is
the wrong authority — an English-speaking admin would silently send English
invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation reaches. This
change gives both invitation sends the same top rung the other four already
read, on a **two-branch** shape:

1. the address (or phone number) **already carries** a `sys_user` row whose
`locale` is set — an existing platform user invited into a second
organization, or a re-invitation — that row's `locale` wins;
2. a genuinely **new** invitee with **no** row keeps the deployment default,
because their language is still truly unknown at invitation time. So does an
invitee whose row exists but names no language: an unset column is not a
choice.

⛔ The inviter direction stays rejected on both branches, and is now pinned
against a manager that has the top rung wired rather than against one with no
rung at all. #13881's ruling item 3 fixes the chain as **recipient** locale →
deployment default; what opened here is the invitee's own column, never the
inviter's header.

**Both branches are reachable, measured rather than assumed.**
`sendInvitationEmail`: better-auth's `create-invitation` route rejects only an
address that is already a member of *this* organization
(`USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION`, `routes/crud-invites.mjs` in
the installed 1.7.2), so an existing account invited elsewhere — and the
`resend` branch — reach the callback normally. `sendPhoneInviteSms` reaches a row by
construction: its one in-repo caller, the identity import endpoint's `invite`
policy, **creates** the account and only then sends the SMS.

⚠️ **What the SMS path yields today, stated precisely, because a changeset
becomes release notes.** The rung is wired there and reads the row whenever the
row carries a locale — but `admin-import-users.ts` never writes `locale` (0
occurrences; positive control: `sendInviteSms` appears twice in the same file),
and `sys_user.locale` declares no column default. So on the only in-repo caller
the column is empty at send time and the invitation SMS still resolves to the
**deployment default** — the pre-change behaviour, unchanged for that flow. What
this buys on that surface is the rung itself: an out-of-repo caller, or a future
import that populates `locale`, is read rather than ignored. The behaviour users
see change today is on the invitation **email**.

**Matching is exact, and that is safe rather than merely tolerable here.**
better-auth lowercases the invitee address on the invite route and the stored
`user.email` on sign-up, so both sides of the predicate are already in the same
case; `email` and `phone_number` are both `unique: true` in the `user` table
`sys_user` is backed by. An address that resolves no row lands on the deployment
default, which is the documented floor rather than a failure — and, as
everywhere else on this ladder, a failing recipient read never blocks a send.

**Docs.** `permissions/authentication.mdx` said "The **invitation** SMS reads
the deployment default alone"; that sentence is now false and is corrected. No
shipped page states the invitation *email* locale rule (the auth email ladder is
undocumented as a whole), so nothing else moved.
5 changes: 3 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,11 +444,12 @@ The OTP and invitation bodies are localised and tenant-customisable: a
`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite,
channel 'sms', locale)` wins — built-in English and Chinese rows are seeded
once (never overwriting your edits) and can be changed under Setup →
Notification Templates. For the **OTP** the locale is the recipient's own
Notification Templates. Both bodies resolve the same way: the recipient's own
`sys_user.locale` when their account has one, and the deployment default
(`localization.locale` setting) otherwise — the account is matched on its
`phone_number`, so a number no account carries takes the deployment default
too. The **invitation** SMS reads the deployment default alone. Whichever
too. For the **invitation** SMS the account normally does exist, because the
identity import endpoint creates it and only then sends the message. Whichever
locale that names is then resolved with a `zh-CN → zh → en` fallback chain;
holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`,
`{{loginUrl}}` (invitation — `{{baseUrl}}`, the bare origin, is still
Expand Down
250 changes: 239 additions & 11 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,12 +10,31 @@
* 2026-08-13 ruling had made the deployment default the whole answer and
* rejected `Accept-Language` outright. #14762 then added the rung ABOVE both,
* per the #14788 option-D ruling of 2026-09-03: the recipient's own
* `sys_user.locale` (#13881) when the account holds one. Invitations keep the
* deployment rung — an invitee has no row until acceptance (#14641) — and
* this file pins that abstention too. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` /
* `emailLocaleArg`; the request rung's own cases and the stored rung's are the
* last two describe blocks in this file.
* `sys_user.locale` (#13881) when the account holds one.
*
* #14641 reached the INVITATION send last, and it is the one send with two
* branches rather than one. The card's terminal state read "choose the
* template by the invitee's stored language", which cannot hold for every
* invitee — an invitee generally has no `sys_user` row until acceptance, so
* there is no stored language to read. What IS implementable, and what this
* file pins, is the two-branch shape:
*
* 1. the address ALREADY carries a `sys_user` row — an existing platform
* user invited into a second organization, or a re-invitation → their own
* `locale`;
* 2. a genuinely new invitee with NO row → the deployment default, because
* their language is still truly unknown at invitation time.
*
* ⛔ The INVITER direction stays rejected on both branches: #13881's ruling
* item 3 fixes the chain as RECIPIENT locale → deployment default, and
* stamping the inviter's `Accept-Language` onto the invitee's mail would move
* the defect one seat over. That abstention is pinned here too, now against a
* manager that HAS the top rung wired — the stronger form of the #14319 pin.
*
* The ruling text of record lives on `AuthManager.setDefaultEmailLocale` /
* `authEmailLocaleFromRequest` / `emailLocaleArg`; the request rung's own
* cases, the stored rung's, and the invitation's two branches are the last
* three describe blocks in this file.
*
* Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`,
* so `EmailService`'s ladder always resolved `en-US` and the localized rows
Expand DownExpand Up@@ -603,10 +622,10 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
expect(sent[0].locale).toBe('zh-CN');
});

it('the INVITATION send is untouched — its rung is #14641\'s', async () => {
// Scope fence, asserted rather than described: an invitee has no sys_user
// row until acceptance, so this send still names the deployment rung even
// when a row for that address would have carried a locale.
it('the INVITATION send reads the SAME rung, on the address — #14641', async () => {
// Was a scope fence ("untouched — its rung is #14641's") until #14641
// landed. The rung is the same one; only the predicate differs, because
// this callback is handed an address rather than a user row.
const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } };
const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
Expand All@@ -617,6 +636,215 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('es-ES');
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('es-ES');
});
});

// ── #14641 — the invitation send's two branches ────────────────────────────

/**
* A `sys_user` table keyed by ADDRESS, so the only thing separating the two
* branches is whether the invitee's address carries a row. One engine object
* is shared between drives wherever a test needs the branches to be provably
* the same lookup — otherwise "no row" and "no read" would be indistinguishable
* from the outside, since both land on the deployment default.
*/
function emailKeyedEngine(rows: Record<string, unknown>) {
const reads: any[] = [];
return {
reads,
engine: {
async findOne(object: string, query: any) {
reads.push({ object, query });
if (object !== 'sys_user') return null;
const email = (query?.where ?? {}).email as string;
return Object.prototype.hasOwnProperty.call(rows, email)
? { locale: rows[email] }
: null;
},
},
};
}

async function driveInvitation(opts: {
engine: unknown;
deployment?: string;
invitee?: string;
/** The INVITER's browser language — better-auth hands this callback its request. */
header?: string;
}) {
const { capturedConfig, sent } = await boot(opts.deployment, {
dataEngine: opts.engine,
} as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail(
{
email: opts.invitee ?? 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
},
opts.header === undefined
? undefined
: new Request('http://x/invite', { headers: { 'accept-language': opts.header } }),
);
return sent;
}

describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () => {
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
beforeEach(() => {
vi.clearAllMocks();
process.env.OS_MCP_SERVER_ENABLED = 'false';
});
afterEach(() => {
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
});

it('BRANCH 1 — an address that already has a row is written in THAT locale', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('zh-CN');
// The direction that makes the pin real: the deployment's own tag is NOT
// what went out.
expect(sent[0].locale).not.toBe('en-US');
});

it('and the reverse — an en-US invitee on a zh-CN deployment gets English', async () => {
// Swapping the two tags is what rules out a pin that would pass because
// one of them always wins.
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'en-US' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN' });
expect(sent[0].locale).toBe('en-US');
expect(sent[0].locale).not.toBe('zh-CN');
});

it('BRANCH 2 — a genuinely new invitee, no row, still takes the deployment default', async () => {
// ⚠️ Positive control for the zero, and the reason ONE engine drives both
// sends: the same table, the same predicate and the same deployment answer
// zh-CN for an address that carries a row and en-US for one that does not.
// That is what separates "the read ran and found nothing" from "the read
// never ran" / "this engine answers nothing" — both of which would also
// land on the deployment default and look identical from the payload.
const { engine, reads } = emailKeyedEngine({ 'known@example.com': 'zh-CN' });

const known = await driveInvitation({ engine, deployment: 'en-US', invitee: 'known@example.com' });
expect(known[0].locale).toBe('zh-CN');

const newcomer = await driveInvitation({ engine, deployment: 'en-US', invitee: 'newcomer@example.com' });
expect(newcomer[0].locale).toBe('en-US');
expect(newcomer[0].locale).not.toBe('zh-CN');

// ...and the newcomer's read really was attempted, on their address.
const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads.map((r) => r.query.where)).toEqual([
{ email: 'known@example.com' },
{ email: 'newcomer@example.com' },
]);
});

it("reads the column off the INVITEE's address — never the inviter's", async () => {
// Establishes WHICH rung produced the value, and on WHOSE identity. The
// inviter has a row too, carrying a different language; it must not be
// reached at all.
const { engine, reads } = emailKeyedEngine({
'invitee@example.com': 'zh-CN',
'dana@example.com': 'ja-JP',
});
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('ja-JP');

const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads).toHaveLength(1);
expect(userReads[0].query.where).toEqual({ email: 'invitee@example.com' });
expect(userReads[0].query.fields).toEqual(['locale']);
expect(userReads[0].query.context?.isSystem).toBe(true);
});

it("⛔ the INVITER's Accept-Language still loses — with the top rung now wired", async () => {
// The #14319 abstention, re-pinned in its stronger form: this send reads a
// recipient rung now, so "no request argument" is no longer trivially true
// of the whole callback. An English-speaking admin must still not force
// English onto a Chinese workspace's new hire.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('en-US');
});

it("...and does not win over the invitee's stored column either", async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'ja-JP' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('en-US');
});

it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => {
for (const junk of ['undefined', 'null', '', ' ', 42, {}]) {
const { engine } = emailKeyedEngine({ 'invitee@example.com': junk });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('en-US');
}
});

it('a failing recipient read never blocks the invitation', async () => {
const engine = { async findOne() { throw new Error('sys_user unavailable'); } };
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent).toHaveLength(1);
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('en-US');
});

it('with no data engine at all, the deployment rung answers exactly as before', async () => {
const { capturedConfig, sent } = await boot('en-US');
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail({
email: 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].locale).toBe('en-US');
});

it('with neither a row nor a deployment default, NO locale is named at all', async () => {
// The ladder's contract is written against an ABSENT key.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine });
expect(sent[0].locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false);
});

it('does not disturb the rest of the invitation payload', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].to).toBe('invitee@example.com');
expect(sent[0].relatedObject).toBe('sys_invitation');
expect(sent[0].relatedId).toBe('inv1');
expect(sent[0].organizationId).toBe('o1');
expect(sent[0].data.organization.name).toBe('Northwind');
expect(sent[0].data.role).toBe('member');
});

it('a placeholder address is still refused BEFORE any recipient read', async () => {
// #2766 V1.5 ordering, re-pinned now that a read sits on this path: the
// refusal must not be preceded by a lookup for an address that is not a
// real recipient.
const { engine, reads } = emailKeyedEngine({});
const { capturedConfig } = await boot('en-US', { dataEngine: engine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await expect(
org._opts.sendInvitationEmail({
email: 'u-abcdefghijklmnopqrst@placeholder.invalid',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
}),
).rejects.toThrow(/placeholder address/);
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
});
});
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
64 changes: 64 additions & 0 deletions .changeset/invitation-invitee-stored-locale.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/plugin-auth": patch
---

fix(auth): an invitation is written in the invitee's own `sys_user.locale` when the address already holds a row, and keeps the deployment default when it does not (#14641)

The four auth sends whose requester IS the recipient gained a per-recipient
language rung in #14762 (`sys_user.locale`, ruled on #13881). The two
**invitation** sends did not, and the recorded reason was structural rather
than an oversight: an invitee generally has no `sys_user` row until they accept,
so there is no stored language to read, and the *inviter's* `Accept-Language` is
the wrong authority — an English-speaking admin would silently send English
invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation reaches. This
change gives both invitation sends the same top rung the other four already
read, on a **two-branch** shape:

1. the address (or phone number) **already carries** a `sys_user` row whose
`locale` is set — an existing platform user invited into a second
organization, or a re-invitation — that row's `locale` wins;
2. a genuinely **new** invitee with **no** row keeps the deployment default,
because their language is still truly unknown at invitation time. So does an
invitee whose row exists but names no language: an unset column is not a
choice.

⛔ The inviter direction stays rejected on both branches, and is now pinned
against a manager that has the top rung wired rather than against one with no
rung at all. #13881's ruling item 3 fixes the chain as **recipient** locale →
deployment default; what opened here is the invitee's own column, never the
inviter's header.

**Both branches are reachable, measured rather than assumed.**
`sendInvitationEmail`: better-auth's `create-invitation` route rejects only an
address that is already a member of *this* organization
(`USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION`, `routes/crud-invites.mjs` in
the installed 1.7.2), so an existing account invited elsewhere — and the
`resend` branch — reach the callback normally. `sendPhoneInviteSms` reaches a row by
construction: its one in-repo caller, the identity import endpoint's `invite`
policy, **creates** the account and only then sends the SMS.

⚠️ **What the SMS path yields today, stated precisely, because a changeset
becomes release notes.** The rung is wired there and reads the row whenever the
row carries a locale — but `admin-import-users.ts` never writes `locale` (0
occurrences; positive control: `sendInviteSms` appears twice in the same file),
and `sys_user.locale` declares no column default. So on the only in-repo caller
the column is empty at send time and the invitation SMS still resolves to the
**deployment default** — the pre-change behaviour, unchanged for that flow. What
this buys on that surface is the rung itself: an out-of-repo caller, or a future
import that populates `locale`, is read rather than ignored. The behaviour users
see change today is on the invitation **email**.

**Matching is exact, and that is safe rather than merely tolerable here.**
better-auth lowercases the invitee address on the invite route and the stored
`user.email` on sign-up, so both sides of the predicate are already in the same
case; `email` and `phone_number` are both `unique: true` in the `user` table
`sys_user` is backed by. An address that resolves no row lands on the deployment
default, which is the documented floor rather than a failure — and, as
everywhere else on this ladder, a failing recipient read never blocks a send.

**Docs.** `permissions/authentication.mdx` said "The **invitation** SMS reads
the deployment default alone"; that sentence is now false and is corrected. No
shipped page states the invitation *email* locale rule (the auth email ladder is
undocumented as a whole), so nothing else moved.
5 changes: 3 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,11 +444,12 @@ The OTP and invitation bodies are localised and tenant-customisable: a
`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite,
channel 'sms', locale)` wins — built-in English and Chinese rows are seeded
once (never overwriting your edits) and can be changed under Setup →
Notification Templates. For the **OTP** the locale is the recipient's own
Notification Templates. Both bodies resolve the same way: the recipient's own
`sys_user.locale` when their account has one, and the deployment default
(`localization.locale` setting) otherwise — the account is matched on its
`phone_number`, so a number no account carries takes the deployment default
too. The **invitation** SMS reads the deployment default alone. Whichever
too. For the **invitation** SMS the account normally does exist, because the
identity import endpoint creates it and only then sends the message. Whichever
locale that names is then resolved with a `zh-CN → zh → en` fallback chain;
holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`,
`{{loginUrl}}` (invitation — `{{baseUrl}}`, the bare origin, is still
Expand Down
250 changes: 239 additions & 11 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,12 +10,31 @@
* 2026-08-13 ruling had made the deployment default the whole answer and
* rejected `Accept-Language` outright. #14762 then added the rung ABOVE both,
* per the #14788 option-D ruling of 2026-09-03: the recipient's own
* `sys_user.locale` (#13881) when the account holds one. Invitations keep the
* deployment rung — an invitee has no row until acceptance (#14641) — and
* this file pins that abstention too. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` /
* `emailLocaleArg`; the request rung's own cases and the stored rung's are the
* last two describe blocks in this file.
* `sys_user.locale` (#13881) when the account holds one.
*
* #14641 reached the INVITATION send last, and it is the one send with two
* branches rather than one. The card's terminal state read "choose the
* template by the invitee's stored language", which cannot hold for every
* invitee — an invitee generally has no `sys_user` row until acceptance, so
* there is no stored language to read. What IS implementable, and what this
* file pins, is the two-branch shape:
*
* 1. the address ALREADY carries a `sys_user` row — an existing platform
* user invited into a second organization, or a re-invitation → their own
* `locale`;
* 2. a genuinely new invitee with NO row → the deployment default, because
* their language is still truly unknown at invitation time.
*
* ⛔ The INVITER direction stays rejected on both branches: #13881's ruling
* item 3 fixes the chain as RECIPIENT locale → deployment default, and
* stamping the inviter's `Accept-Language` onto the invitee's mail would move
* the defect one seat over. That abstention is pinned here too, now against a
* manager that HAS the top rung wired — the stronger form of the #14319 pin.
*
* The ruling text of record lives on `AuthManager.setDefaultEmailLocale` /
* `authEmailLocaleFromRequest` / `emailLocaleArg`; the request rung's own
* cases, the stored rung's, and the invitation's two branches are the last
* three describe blocks in this file.
*
* Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`,
* so `EmailService`'s ladder always resolved `en-US` and the localized rows
Expand DownExpand Up@@ -603,10 +622,10 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
expect(sent[0].locale).toBe('zh-CN');
});

it('the INVITATION send is untouched — its rung is #14641\'s', async () => {
// Scope fence, asserted rather than described: an invitee has no sys_user
// row until acceptance, so this send still names the deployment rung even
// when a row for that address would have carried a locale.
it('the INVITATION send reads the SAME rung, on the address — #14641', async () => {
// Was a scope fence ("untouched — its rung is #14641's") until #14641
// landed. The rung is the same one; only the predicate differs, because
// this callback is handed an address rather than a user row.
const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } };
const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
Expand All@@ -617,6 +636,215 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('es-ES');
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('es-ES');
});
});

// ── #14641 — the invitation send's two branches ────────────────────────────

/**
* A `sys_user` table keyed by ADDRESS, so the only thing separating the two
* branches is whether the invitee's address carries a row. One engine object
* is shared between drives wherever a test needs the branches to be provably
* the same lookup — otherwise "no row" and "no read" would be indistinguishable
* from the outside, since both land on the deployment default.
*/
function emailKeyedEngine(rows: Record<string, unknown>) {
const reads: any[] = [];
return {
reads,
engine: {
async findOne(object: string, query: any) {
reads.push({ object, query });
if (object !== 'sys_user') return null;
const email = (query?.where ?? {}).email as string;
return Object.prototype.hasOwnProperty.call(rows, email)
? { locale: rows[email] }
: null;
},
},
};
}

async function driveInvitation(opts: {
engine: unknown;
deployment?: string;
invitee?: string;
/** The INVITER's browser language — better-auth hands this callback its request. */
header?: string;
}) {
const { capturedConfig, sent } = await boot(opts.deployment, {
dataEngine: opts.engine,
} as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail(
{
email: opts.invitee ?? 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
},
opts.header === undefined
? undefined
: new Request('http://x/invite', { headers: { 'accept-language': opts.header } }),
);
return sent;
}

describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () => {
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
beforeEach(() => {
vi.clearAllMocks();
process.env.OS_MCP_SERVER_ENABLED = 'false';
});
afterEach(() => {
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
});

it('BRANCH 1 — an address that already has a row is written in THAT locale', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('zh-CN');
// The direction that makes the pin real: the deployment's own tag is NOT
// what went out.
expect(sent[0].locale).not.toBe('en-US');
});

it('and the reverse — an en-US invitee on a zh-CN deployment gets English', async () => {
// Swapping the two tags is what rules out a pin that would pass because
// one of them always wins.
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'en-US' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN' });
expect(sent[0].locale).toBe('en-US');
expect(sent[0].locale).not.toBe('zh-CN');
});

it('BRANCH 2 — a genuinely new invitee, no row, still takes the deployment default', async () => {
// ⚠️ Positive control for the zero, and the reason ONE engine drives both
// sends: the same table, the same predicate and the same deployment answer
// zh-CN for an address that carries a row and en-US for one that does not.
// That is what separates "the read ran and found nothing" from "the read
// never ran" / "this engine answers nothing" — both of which would also
// land on the deployment default and look identical from the payload.
const { engine, reads } = emailKeyedEngine({ 'known@example.com': 'zh-CN' });

const known = await driveInvitation({ engine, deployment: 'en-US', invitee: 'known@example.com' });
expect(known[0].locale).toBe('zh-CN');

const newcomer = await driveInvitation({ engine, deployment: 'en-US', invitee: 'newcomer@example.com' });
expect(newcomer[0].locale).toBe('en-US');
expect(newcomer[0].locale).not.toBe('zh-CN');

// ...and the newcomer's read really was attempted, on their address.
const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads.map((r) => r.query.where)).toEqual([
{ email: 'known@example.com' },
{ email: 'newcomer@example.com' },
]);
});

it("reads the column off the INVITEE's address — never the inviter's", async () => {
// Establishes WHICH rung produced the value, and on WHOSE identity. The
// inviter has a row too, carrying a different language; it must not be
// reached at all.
const { engine, reads } = emailKeyedEngine({
'invitee@example.com': 'zh-CN',
'dana@example.com': 'ja-JP',
});
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('ja-JP');

const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads).toHaveLength(1);
expect(userReads[0].query.where).toEqual({ email: 'invitee@example.com' });
expect(userReads[0].query.fields).toEqual(['locale']);
expect(userReads[0].query.context?.isSystem).toBe(true);
});

it("⛔ the INVITER's Accept-Language still loses — with the top rung now wired", async () => {
// The #14319 abstention, re-pinned in its stronger form: this send reads a
// recipient rung now, so "no request argument" is no longer trivially true
// of the whole callback. An English-speaking admin must still not force
// English onto a Chinese workspace's new hire.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('en-US');
});

it("...and does not win over the invitee's stored column either", async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'ja-JP' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('en-US');
});

it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => {
for (const junk of ['undefined', 'null', '', ' ', 42, {}]) {
const { engine } = emailKeyedEngine({ 'invitee@example.com': junk });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('en-US');
}
});

it('a failing recipient read never blocks the invitation', async () => {
const engine = { async findOne() { throw new Error('sys_user unavailable'); } };
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent).toHaveLength(1);
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('en-US');
});

it('with no data engine at all, the deployment rung answers exactly as before', async () => {
const { capturedConfig, sent } = await boot('en-US');
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail({
email: 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].locale).toBe('en-US');
});

it('with neither a row nor a deployment default, NO locale is named at all', async () => {
// The ladder's contract is written against an ABSENT key.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine });
expect(sent[0].locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false);
});

it('does not disturb the rest of the invitation payload', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].to).toBe('invitee@example.com');
expect(sent[0].relatedObject).toBe('sys_invitation');
expect(sent[0].relatedId).toBe('inv1');
expect(sent[0].organizationId).toBe('o1');
expect(sent[0].data.organization.name).toBe('Northwind');
expect(sent[0].data.role).toBe('member');
});

it('a placeholder address is still refused BEFORE any recipient read', async () => {
// #2766 V1.5 ordering, re-pinned now that a read sits on this path: the
// refusal must not be preceded by a lookup for an address that is not a
// real recipient.
const { engine, reads } = emailKeyedEngine({});
const { capturedConfig } = await boot('en-US', { dataEngine: engine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await expect(
org._opts.sendInvitationEmail({
email: 'u-abcdefghijklmnopqrst@placeholder.invalid',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
}),
).rejects.toThrow(/placeholder address/);
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
});
});
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
64 changes: 64 additions & 0 deletions .changeset/invitation-invitee-stored-locale.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/plugin-auth": patch
---

fix(auth): an invitation is written in the invitee's own `sys_user.locale` when the address already holds a row, and keeps the deployment default when it does not (#14641)

The four auth sends whose requester IS the recipient gained a per-recipient
language rung in #14762 (`sys_user.locale`, ruled on #13881). The two
**invitation** sends did not, and the recorded reason was structural rather
than an oversight: an invitee generally has no `sys_user` row until they accept,
so there is no stored language to read, and the *inviter's* `Accept-Language` is
the wrong authority — an English-speaking admin would silently send English
invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation reaches. This
change gives both invitation sends the same top rung the other four already
read, on a **two-branch** shape:

1. the address (or phone number) **already carries** a `sys_user` row whose
`locale` is set — an existing platform user invited into a second
organization, or a re-invitation — that row's `locale` wins;
2. a genuinely **new** invitee with **no** row keeps the deployment default,
because their language is still truly unknown at invitation time. So does an
invitee whose row exists but names no language: an unset column is not a
choice.

⛔ The inviter direction stays rejected on both branches, and is now pinned
against a manager that has the top rung wired rather than against one with no
rung at all. #13881's ruling item 3 fixes the chain as **recipient** locale →
deployment default; what opened here is the invitee's own column, never the
inviter's header.

**Both branches are reachable, measured rather than assumed.**
`sendInvitationEmail`: better-auth's `create-invitation` route rejects only an
address that is already a member of *this* organization
(`USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION`, `routes/crud-invites.mjs` in
the installed 1.7.2), so an existing account invited elsewhere — and the
`resend` branch — reach the callback normally. `sendPhoneInviteSms` reaches a row by
construction: its one in-repo caller, the identity import endpoint's `invite`
policy, **creates** the account and only then sends the SMS.

⚠️ **What the SMS path yields today, stated precisely, because a changeset
becomes release notes.** The rung is wired there and reads the row whenever the
row carries a locale — but `admin-import-users.ts` never writes `locale` (0
occurrences; positive control: `sendInviteSms` appears twice in the same file),
and `sys_user.locale` declares no column default. So on the only in-repo caller
the column is empty at send time and the invitation SMS still resolves to the
**deployment default** — the pre-change behaviour, unchanged for that flow. What
this buys on that surface is the rung itself: an out-of-repo caller, or a future
import that populates `locale`, is read rather than ignored. The behaviour users
see change today is on the invitation **email**.

**Matching is exact, and that is safe rather than merely tolerable here.**
better-auth lowercases the invitee address on the invite route and the stored
`user.email` on sign-up, so both sides of the predicate are already in the same
case; `email` and `phone_number` are both `unique: true` in the `user` table
`sys_user` is backed by. An address that resolves no row lands on the deployment
default, which is the documented floor rather than a failure — and, as
everywhere else on this ladder, a failing recipient read never blocks a send.

**Docs.** `permissions/authentication.mdx` said "The **invitation** SMS reads
the deployment default alone"; that sentence is now false and is corrected. No
shipped page states the invitation *email* locale rule (the auth email ladder is
undocumented as a whole), so nothing else moved.
5 changes: 3 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,11 +444,12 @@ The OTP and invitation bodies are localised and tenant-customisable: a
`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite,
channel 'sms', locale)` wins — built-in English and Chinese rows are seeded
once (never overwriting your edits) and can be changed under Setup →
Notification Templates. For the **OTP** the locale is the recipient's own
Notification Templates. Both bodies resolve the same way: the recipient's own
`sys_user.locale` when their account has one, and the deployment default
(`localization.locale` setting) otherwise — the account is matched on its
`phone_number`, so a number no account carries takes the deployment default
too. The **invitation** SMS reads the deployment default alone. Whichever
too. For the **invitation** SMS the account normally does exist, because the
identity import endpoint creates it and only then sends the message. Whichever
locale that names is then resolved with a `zh-CN → zh → en` fallback chain;
holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`,
`{{loginUrl}}` (invitation — `{{baseUrl}}`, the bare origin, is still
Expand Down
250 changes: 239 additions & 11 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,12 +10,31 @@
* 2026-08-13 ruling had made the deployment default the whole answer and
* rejected `Accept-Language` outright. #14762 then added the rung ABOVE both,
* per the #14788 option-D ruling of 2026-09-03: the recipient's own
* `sys_user.locale` (#13881) when the account holds one. Invitations keep the
* deployment rung — an invitee has no row until acceptance (#14641) — and
* this file pins that abstention too. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` /
* `emailLocaleArg`; the request rung's own cases and the stored rung's are the
* last two describe blocks in this file.
* `sys_user.locale` (#13881) when the account holds one.
*
* #14641 reached the INVITATION send last, and it is the one send with two
* branches rather than one. The card's terminal state read "choose the
* template by the invitee's stored language", which cannot hold for every
* invitee — an invitee generally has no `sys_user` row until acceptance, so
* there is no stored language to read. What IS implementable, and what this
* file pins, is the two-branch shape:
*
* 1. the address ALREADY carries a `sys_user` row — an existing platform
* user invited into a second organization, or a re-invitation → their own
* `locale`;
* 2. a genuinely new invitee with NO row → the deployment default, because
* their language is still truly unknown at invitation time.
*
* ⛔ The INVITER direction stays rejected on both branches: #13881's ruling
* item 3 fixes the chain as RECIPIENT locale → deployment default, and
* stamping the inviter's `Accept-Language` onto the invitee's mail would move
* the defect one seat over. That abstention is pinned here too, now against a
* manager that HAS the top rung wired — the stronger form of the #14319 pin.
*
* The ruling text of record lives on `AuthManager.setDefaultEmailLocale` /
* `authEmailLocaleFromRequest` / `emailLocaleArg`; the request rung's own
* cases, the stored rung's, and the invitation's two branches are the last
* three describe blocks in this file.
*
* Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`,
* so `EmailService`'s ladder always resolved `en-US` and the localized rows
Expand DownExpand Up@@ -603,10 +622,10 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
expect(sent[0].locale).toBe('zh-CN');
});

it('the INVITATION send is untouched — its rung is #14641\'s', async () => {
// Scope fence, asserted rather than described: an invitee has no sys_user
// row until acceptance, so this send still names the deployment rung even
// when a row for that address would have carried a locale.
it('the INVITATION send reads the SAME rung, on the address — #14641', async () => {
// Was a scope fence ("untouched — its rung is #14641's") until #14641
// landed. The rung is the same one; only the predicate differs, because
// this callback is handed an address rather than a user row.
const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } };
const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
Expand All@@ -617,6 +636,215 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('es-ES');
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('es-ES');
});
});

// ── #14641 — the invitation send's two branches ────────────────────────────

/**
* A `sys_user` table keyed by ADDRESS, so the only thing separating the two
* branches is whether the invitee's address carries a row. One engine object
* is shared between drives wherever a test needs the branches to be provably
* the same lookup — otherwise "no row" and "no read" would be indistinguishable
* from the outside, since both land on the deployment default.
*/
function emailKeyedEngine(rows: Record<string, unknown>) {
const reads: any[] = [];
return {
reads,
engine: {
async findOne(object: string, query: any) {
reads.push({ object, query });
if (object !== 'sys_user') return null;
const email = (query?.where ?? {}).email as string;
return Object.prototype.hasOwnProperty.call(rows, email)
? { locale: rows[email] }
: null;
},
},
};
}

async function driveInvitation(opts: {
engine: unknown;
deployment?: string;
invitee?: string;
/** The INVITER's browser language — better-auth hands this callback its request. */
header?: string;
}) {
const { capturedConfig, sent } = await boot(opts.deployment, {
dataEngine: opts.engine,
} as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail(
{
email: opts.invitee ?? 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
},
opts.header === undefined
? undefined
: new Request('http://x/invite', { headers: { 'accept-language': opts.header } }),
);
return sent;
}

describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () => {
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
beforeEach(() => {
vi.clearAllMocks();
process.env.OS_MCP_SERVER_ENABLED = 'false';
});
afterEach(() => {
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
});

it('BRANCH 1 — an address that already has a row is written in THAT locale', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('zh-CN');
// The direction that makes the pin real: the deployment's own tag is NOT
// what went out.
expect(sent[0].locale).not.toBe('en-US');
});

it('and the reverse — an en-US invitee on a zh-CN deployment gets English', async () => {
// Swapping the two tags is what rules out a pin that would pass because
// one of them always wins.
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'en-US' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN' });
expect(sent[0].locale).toBe('en-US');
expect(sent[0].locale).not.toBe('zh-CN');
});

it('BRANCH 2 — a genuinely new invitee, no row, still takes the deployment default', async () => {
// ⚠️ Positive control for the zero, and the reason ONE engine drives both
// sends: the same table, the same predicate and the same deployment answer
// zh-CN for an address that carries a row and en-US for one that does not.
// That is what separates "the read ran and found nothing" from "the read
// never ran" / "this engine answers nothing" — both of which would also
// land on the deployment default and look identical from the payload.
const { engine, reads } = emailKeyedEngine({ 'known@example.com': 'zh-CN' });

const known = await driveInvitation({ engine, deployment: 'en-US', invitee: 'known@example.com' });
expect(known[0].locale).toBe('zh-CN');

const newcomer = await driveInvitation({ engine, deployment: 'en-US', invitee: 'newcomer@example.com' });
expect(newcomer[0].locale).toBe('en-US');
expect(newcomer[0].locale).not.toBe('zh-CN');

// ...and the newcomer's read really was attempted, on their address.
const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads.map((r) => r.query.where)).toEqual([
{ email: 'known@example.com' },
{ email: 'newcomer@example.com' },
]);
});

it("reads the column off the INVITEE's address — never the inviter's", async () => {
// Establishes WHICH rung produced the value, and on WHOSE identity. The
// inviter has a row too, carrying a different language; it must not be
// reached at all.
const { engine, reads } = emailKeyedEngine({
'invitee@example.com': 'zh-CN',
'dana@example.com': 'ja-JP',
});
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('ja-JP');

const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads).toHaveLength(1);
expect(userReads[0].query.where).toEqual({ email: 'invitee@example.com' });
expect(userReads[0].query.fields).toEqual(['locale']);
expect(userReads[0].query.context?.isSystem).toBe(true);
});

it("⛔ the INVITER's Accept-Language still loses — with the top rung now wired", async () => {
// The #14319 abstention, re-pinned in its stronger form: this send reads a
// recipient rung now, so "no request argument" is no longer trivially true
// of the whole callback. An English-speaking admin must still not force
// English onto a Chinese workspace's new hire.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('en-US');
});

it("...and does not win over the invitee's stored column either", async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'ja-JP' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('en-US');
});

it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => {
for (const junk of ['undefined', 'null', '', ' ', 42, {}]) {
const { engine } = emailKeyedEngine({ 'invitee@example.com': junk });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('en-US');
}
});

it('a failing recipient read never blocks the invitation', async () => {
const engine = { async findOne() { throw new Error('sys_user unavailable'); } };
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent).toHaveLength(1);
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('en-US');
});

it('with no data engine at all, the deployment rung answers exactly as before', async () => {
const { capturedConfig, sent } = await boot('en-US');
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail({
email: 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].locale).toBe('en-US');
});

it('with neither a row nor a deployment default, NO locale is named at all', async () => {
// The ladder's contract is written against an ABSENT key.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine });
expect(sent[0].locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false);
});

it('does not disturb the rest of the invitation payload', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].to).toBe('invitee@example.com');
expect(sent[0].relatedObject).toBe('sys_invitation');
expect(sent[0].relatedId).toBe('inv1');
expect(sent[0].organizationId).toBe('o1');
expect(sent[0].data.organization.name).toBe('Northwind');
expect(sent[0].data.role).toBe('member');
});

it('a placeholder address is still refused BEFORE any recipient read', async () => {
// #2766 V1.5 ordering, re-pinned now that a read sits on this path: the
// refusal must not be preceded by a lookup for an address that is not a
// real recipient.
const { engine, reads } = emailKeyedEngine({});
const { capturedConfig } = await boot('en-US', { dataEngine: engine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await expect(
org._opts.sendInvitationEmail({
email: 'u-abcdefghijklmnopqrst@placeholder.invalid',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
}),
).rejects.toThrow(/placeholder address/);
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
});
});
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
64 changes: 64 additions & 0 deletions .changeset/invitation-invitee-stored-locale.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/plugin-auth": patch
---

fix(auth): an invitation is written in the invitee's own `sys_user.locale` when the address already holds a row, and keeps the deployment default when it does not (#14641)

The four auth sends whose requester IS the recipient gained a per-recipient
language rung in #14762 (`sys_user.locale`, ruled on #13881). The two
**invitation** sends did not, and the recorded reason was structural rather
than an oversight: an invitee generally has no `sys_user` row until they accept,
so there is no stored language to read, and the *inviter's* `Accept-Language` is
the wrong authority — an English-speaking admin would silently send English
invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation reaches. This
change gives both invitation sends the same top rung the other four already
read, on a **two-branch** shape:

1. the address (or phone number) **already carries** a `sys_user` row whose
`locale` is set — an existing platform user invited into a second
organization, or a re-invitation — that row's `locale` wins;
2. a genuinely **new** invitee with **no** row keeps the deployment default,
because their language is still truly unknown at invitation time. So does an
invitee whose row exists but names no language: an unset column is not a
choice.

⛔ The inviter direction stays rejected on both branches, and is now pinned
against a manager that has the top rung wired rather than against one with no
rung at all. #13881's ruling item 3 fixes the chain as **recipient** locale →
deployment default; what opened here is the invitee's own column, never the
inviter's header.

**Both branches are reachable, measured rather than assumed.**
`sendInvitationEmail`: better-auth's `create-invitation` route rejects only an
address that is already a member of *this* organization
(`USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION`, `routes/crud-invites.mjs` in
the installed 1.7.2), so an existing account invited elsewhere — and the
`resend` branch — reach the callback normally. `sendPhoneInviteSms` reaches a row by
construction: its one in-repo caller, the identity import endpoint's `invite`
policy, **creates** the account and only then sends the SMS.

⚠️ **What the SMS path yields today, stated precisely, because a changeset
becomes release notes.** The rung is wired there and reads the row whenever the
row carries a locale — but `admin-import-users.ts` never writes `locale` (0
occurrences; positive control: `sendInviteSms` appears twice in the same file),
and `sys_user.locale` declares no column default. So on the only in-repo caller
the column is empty at send time and the invitation SMS still resolves to the
**deployment default** — the pre-change behaviour, unchanged for that flow. What
this buys on that surface is the rung itself: an out-of-repo caller, or a future
import that populates `locale`, is read rather than ignored. The behaviour users
see change today is on the invitation **email**.

**Matching is exact, and that is safe rather than merely tolerable here.**
better-auth lowercases the invitee address on the invite route and the stored
`user.email` on sign-up, so both sides of the predicate are already in the same
case; `email` and `phone_number` are both `unique: true` in the `user` table
`sys_user` is backed by. An address that resolves no row lands on the deployment
default, which is the documented floor rather than a failure — and, as
everywhere else on this ladder, a failing recipient read never blocks a send.

**Docs.** `permissions/authentication.mdx` said "The **invitation** SMS reads
the deployment default alone"; that sentence is now false and is corrected. No
shipped page states the invitation *email* locale rule (the auth email ladder is
undocumented as a whole), so nothing else moved.
5 changes: 3 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,11 +444,12 @@ The OTP and invitation bodies are localised and tenant-customisable: a
`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite,
channel 'sms', locale)` wins — built-in English and Chinese rows are seeded
once (never overwriting your edits) and can be changed under Setup →
Notification Templates. For the **OTP** the locale is the recipient's own
Notification Templates. Both bodies resolve the same way: the recipient's own
`sys_user.locale` when their account has one, and the deployment default
(`localization.locale` setting) otherwise — the account is matched on its
`phone_number`, so a number no account carries takes the deployment default
too. The **invitation** SMS reads the deployment default alone. Whichever
too. For the **invitation** SMS the account normally does exist, because the
identity import endpoint creates it and only then sends the message. Whichever
locale that names is then resolved with a `zh-CN → zh → en` fallback chain;
holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`,
`{{loginUrl}}` (invitation — `{{baseUrl}}`, the bare origin, is still
Expand Down
250 changes: 239 additions & 11 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,12 +10,31 @@
* 2026-08-13 ruling had made the deployment default the whole answer and
* rejected `Accept-Language` outright. #14762 then added the rung ABOVE both,
* per the #14788 option-D ruling of 2026-09-03: the recipient's own
* `sys_user.locale` (#13881) when the account holds one. Invitations keep the
* deployment rung — an invitee has no row until acceptance (#14641) — and
* this file pins that abstention too. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` /
* `emailLocaleArg`; the request rung's own cases and the stored rung's are the
* last two describe blocks in this file.
* `sys_user.locale` (#13881) when the account holds one.
*
* #14641 reached the INVITATION send last, and it is the one send with two
* branches rather than one. The card's terminal state read "choose the
* template by the invitee's stored language", which cannot hold for every
* invitee — an invitee generally has no `sys_user` row until acceptance, so
* there is no stored language to read. What IS implementable, and what this
* file pins, is the two-branch shape:
*
* 1. the address ALREADY carries a `sys_user` row — an existing platform
* user invited into a second organization, or a re-invitation → their own
* `locale`;
* 2. a genuinely new invitee with NO row → the deployment default, because
* their language is still truly unknown at invitation time.
*
* ⛔ The INVITER direction stays rejected on both branches: #13881's ruling
* item 3 fixes the chain as RECIPIENT locale → deployment default, and
* stamping the inviter's `Accept-Language` onto the invitee's mail would move
* the defect one seat over. That abstention is pinned here too, now against a
* manager that HAS the top rung wired — the stronger form of the #14319 pin.
*
* The ruling text of record lives on `AuthManager.setDefaultEmailLocale` /
* `authEmailLocaleFromRequest` / `emailLocaleArg`; the request rung's own
* cases, the stored rung's, and the invitation's two branches are the last
* three describe blocks in this file.
*
* Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`,
* so `EmailService`'s ladder always resolved `en-US` and the localized rows
Expand DownExpand Up@@ -603,10 +622,10 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
expect(sent[0].locale).toBe('zh-CN');
});

it('the INVITATION send is untouched — its rung is #14641\'s', async () => {
// Scope fence, asserted rather than described: an invitee has no sys_user
// row until acceptance, so this send still names the deployment rung even
// when a row for that address would have carried a locale.
it('the INVITATION send reads the SAME rung, on the address — #14641', async () => {
// Was a scope fence ("untouched — its rung is #14641's") until #14641
// landed. The rung is the same one; only the predicate differs, because
// this callback is handed an address rather than a user row.
const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } };
const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
Expand All@@ -617,6 +636,215 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('es-ES');
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('es-ES');
});
});

// ── #14641 — the invitation send's two branches ────────────────────────────

/**
* A `sys_user` table keyed by ADDRESS, so the only thing separating the two
* branches is whether the invitee's address carries a row. One engine object
* is shared between drives wherever a test needs the branches to be provably
* the same lookup — otherwise "no row" and "no read" would be indistinguishable
* from the outside, since both land on the deployment default.
*/
function emailKeyedEngine(rows: Record<string, unknown>) {
const reads: any[] = [];
return {
reads,
engine: {
async findOne(object: string, query: any) {
reads.push({ object, query });
if (object !== 'sys_user') return null;
const email = (query?.where ?? {}).email as string;
return Object.prototype.hasOwnProperty.call(rows, email)
? { locale: rows[email] }
: null;
},
},
};
}

async function driveInvitation(opts: {
engine: unknown;
deployment?: string;
invitee?: string;
/** The INVITER's browser language — better-auth hands this callback its request. */
header?: string;
}) {
const { capturedConfig, sent } = await boot(opts.deployment, {
dataEngine: opts.engine,
} as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail(
{
email: opts.invitee ?? 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
},
opts.header === undefined
? undefined
: new Request('http://x/invite', { headers: { 'accept-language': opts.header } }),
);
return sent;
}

describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () => {
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
beforeEach(() => {
vi.clearAllMocks();
process.env.OS_MCP_SERVER_ENABLED = 'false';
});
afterEach(() => {
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
});

it('BRANCH 1 — an address that already has a row is written in THAT locale', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('zh-CN');
// The direction that makes the pin real: the deployment's own tag is NOT
// what went out.
expect(sent[0].locale).not.toBe('en-US');
});

it('and the reverse — an en-US invitee on a zh-CN deployment gets English', async () => {
// Swapping the two tags is what rules out a pin that would pass because
// one of them always wins.
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'en-US' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN' });
expect(sent[0].locale).toBe('en-US');
expect(sent[0].locale).not.toBe('zh-CN');
});

it('BRANCH 2 — a genuinely new invitee, no row, still takes the deployment default', async () => {
// ⚠️ Positive control for the zero, and the reason ONE engine drives both
// sends: the same table, the same predicate and the same deployment answer
// zh-CN for an address that carries a row and en-US for one that does not.
// That is what separates "the read ran and found nothing" from "the read
// never ran" / "this engine answers nothing" — both of which would also
// land on the deployment default and look identical from the payload.
const { engine, reads } = emailKeyedEngine({ 'known@example.com': 'zh-CN' });

const known = await driveInvitation({ engine, deployment: 'en-US', invitee: 'known@example.com' });
expect(known[0].locale).toBe('zh-CN');

const newcomer = await driveInvitation({ engine, deployment: 'en-US', invitee: 'newcomer@example.com' });
expect(newcomer[0].locale).toBe('en-US');
expect(newcomer[0].locale).not.toBe('zh-CN');

// ...and the newcomer's read really was attempted, on their address.
const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads.map((r) => r.query.where)).toEqual([
{ email: 'known@example.com' },
{ email: 'newcomer@example.com' },
]);
});

it("reads the column off the INVITEE's address — never the inviter's", async () => {
// Establishes WHICH rung produced the value, and on WHOSE identity. The
// inviter has a row too, carrying a different language; it must not be
// reached at all.
const { engine, reads } = emailKeyedEngine({
'invitee@example.com': 'zh-CN',
'dana@example.com': 'ja-JP',
});
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('ja-JP');

const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads).toHaveLength(1);
expect(userReads[0].query.where).toEqual({ email: 'invitee@example.com' });
expect(userReads[0].query.fields).toEqual(['locale']);
expect(userReads[0].query.context?.isSystem).toBe(true);
});

it("⛔ the INVITER's Accept-Language still loses — with the top rung now wired", async () => {
// The #14319 abstention, re-pinned in its stronger form: this send reads a
// recipient rung now, so "no request argument" is no longer trivially true
// of the whole callback. An English-speaking admin must still not force
// English onto a Chinese workspace's new hire.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('en-US');
});

it("...and does not win over the invitee's stored column either", async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'ja-JP' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('en-US');
});

it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => {
for (const junk of ['undefined', 'null', '', ' ', 42, {}]) {
const { engine } = emailKeyedEngine({ 'invitee@example.com': junk });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('en-US');
}
});

it('a failing recipient read never blocks the invitation', async () => {
const engine = { async findOne() { throw new Error('sys_user unavailable'); } };
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent).toHaveLength(1);
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('en-US');
});

it('with no data engine at all, the deployment rung answers exactly as before', async () => {
const { capturedConfig, sent } = await boot('en-US');
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail({
email: 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].locale).toBe('en-US');
});

it('with neither a row nor a deployment default, NO locale is named at all', async () => {
// The ladder's contract is written against an ABSENT key.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine });
expect(sent[0].locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false);
});

it('does not disturb the rest of the invitation payload', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].to).toBe('invitee@example.com');
expect(sent[0].relatedObject).toBe('sys_invitation');
expect(sent[0].relatedId).toBe('inv1');
expect(sent[0].organizationId).toBe('o1');
expect(sent[0].data.organization.name).toBe('Northwind');
expect(sent[0].data.role).toBe('member');
});

it('a placeholder address is still refused BEFORE any recipient read', async () => {
// #2766 V1.5 ordering, re-pinned now that a read sits on this path: the
// refusal must not be preceded by a lookup for an address that is not a
// real recipient.
const { engine, reads } = emailKeyedEngine({});
const { capturedConfig } = await boot('en-US', { dataEngine: engine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await expect(
org._opts.sendInvitationEmail({
email: 'u-abcdefghijklmnopqrst@placeholder.invalid',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
}),
).rejects.toThrow(/placeholder address/);
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
});
});
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
64 changes: 64 additions & 0 deletions .changeset/invitation-invitee-stored-locale.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/plugin-auth": patch
---

fix(auth): an invitation is written in the invitee's own `sys_user.locale` when the address already holds a row, and keeps the deployment default when it does not (#14641)

The four auth sends whose requester IS the recipient gained a per-recipient
language rung in #14762 (`sys_user.locale`, ruled on #13881). The two
**invitation** sends did not, and the recorded reason was structural rather
than an oversight: an invitee generally has no `sys_user` row until they accept,
so there is no stored language to read, and the *inviter's* `Accept-Language` is
the wrong authority — an English-speaking admin would silently send English
invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation reaches. This
change gives both invitation sends the same top rung the other four already
read, on a **two-branch** shape:

1. the address (or phone number) **already carries** a `sys_user` row whose
`locale` is set — an existing platform user invited into a second
organization, or a re-invitation — that row's `locale` wins;
2. a genuinely **new** invitee with **no** row keeps the deployment default,
because their language is still truly unknown at invitation time. So does an
invitee whose row exists but names no language: an unset column is not a
choice.

⛔ The inviter direction stays rejected on both branches, and is now pinned
against a manager that has the top rung wired rather than against one with no
rung at all. #13881's ruling item 3 fixes the chain as **recipient** locale →
deployment default; what opened here is the invitee's own column, never the
inviter's header.

**Both branches are reachable, measured rather than assumed.**
`sendInvitationEmail`: better-auth's `create-invitation` route rejects only an
address that is already a member of *this* organization
(`USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION`, `routes/crud-invites.mjs` in
the installed 1.7.2), so an existing account invited elsewhere — and the
`resend` branch — reach the callback normally. `sendPhoneInviteSms` reaches a row by
construction: its one in-repo caller, the identity import endpoint's `invite`
policy, **creates** the account and only then sends the SMS.

⚠️ **What the SMS path yields today, stated precisely, because a changeset
becomes release notes.** The rung is wired there and reads the row whenever the
row carries a locale — but `admin-import-users.ts` never writes `locale` (0
occurrences; positive control: `sendInviteSms` appears twice in the same file),
and `sys_user.locale` declares no column default. So on the only in-repo caller
the column is empty at send time and the invitation SMS still resolves to the
**deployment default** — the pre-change behaviour, unchanged for that flow. What
this buys on that surface is the rung itself: an out-of-repo caller, or a future
import that populates `locale`, is read rather than ignored. The behaviour users
see change today is on the invitation **email**.

**Matching is exact, and that is safe rather than merely tolerable here.**
better-auth lowercases the invitee address on the invite route and the stored
`user.email` on sign-up, so both sides of the predicate are already in the same
case; `email` and `phone_number` are both `unique: true` in the `user` table
`sys_user` is backed by. An address that resolves no row lands on the deployment
default, which is the documented floor rather than a failure — and, as
everywhere else on this ladder, a failing recipient read never blocks a send.

**Docs.** `permissions/authentication.mdx` said "The **invitation** SMS reads
the deployment default alone"; that sentence is now false and is corrected. No
shipped page states the invitation *email* locale rule (the auth email ladder is
undocumented as a whole), so nothing else moved.
5 changes: 3 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,11 +444,12 @@ The OTP and invitation bodies are localised and tenant-customisable: a
`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite,
channel 'sms', locale)` wins — built-in English and Chinese rows are seeded
once (never overwriting your edits) and can be changed under Setup →
Notification Templates. For the **OTP** the locale is the recipient's own
Notification Templates. Both bodies resolve the same way: the recipient's own
`sys_user.locale` when their account has one, and the deployment default
(`localization.locale` setting) otherwise — the account is matched on its
`phone_number`, so a number no account carries takes the deployment default
too. The **invitation** SMS reads the deployment default alone. Whichever
too. For the **invitation** SMS the account normally does exist, because the
identity import endpoint creates it and only then sends the message. Whichever
locale that names is then resolved with a `zh-CN → zh → en` fallback chain;
holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`,
`{{loginUrl}}` (invitation — `{{baseUrl}}`, the bare origin, is still
Expand Down
250 changes: 239 additions & 11 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,12 +10,31 @@
* 2026-08-13 ruling had made the deployment default the whole answer and
* rejected `Accept-Language` outright. #14762 then added the rung ABOVE both,
* per the #14788 option-D ruling of 2026-09-03: the recipient's own
* `sys_user.locale` (#13881) when the account holds one. Invitations keep the
* deployment rung — an invitee has no row until acceptance (#14641) — and
* this file pins that abstention too. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` /
* `emailLocaleArg`; the request rung's own cases and the stored rung's are the
* last two describe blocks in this file.
* `sys_user.locale` (#13881) when the account holds one.
*
* #14641 reached the INVITATION send last, and it is the one send with two
* branches rather than one. The card's terminal state read "choose the
* template by the invitee's stored language", which cannot hold for every
* invitee — an invitee generally has no `sys_user` row until acceptance, so
* there is no stored language to read. What IS implementable, and what this
* file pins, is the two-branch shape:
*
* 1. the address ALREADY carries a `sys_user` row — an existing platform
* user invited into a second organization, or a re-invitation → their own
* `locale`;
* 2. a genuinely new invitee with NO row → the deployment default, because
* their language is still truly unknown at invitation time.
*
* ⛔ The INVITER direction stays rejected on both branches: #13881's ruling
* item 3 fixes the chain as RECIPIENT locale → deployment default, and
* stamping the inviter's `Accept-Language` onto the invitee's mail would move
* the defect one seat over. That abstention is pinned here too, now against a
* manager that HAS the top rung wired — the stronger form of the #14319 pin.
*
* The ruling text of record lives on `AuthManager.setDefaultEmailLocale` /
* `authEmailLocaleFromRequest` / `emailLocaleArg`; the request rung's own
* cases, the stored rung's, and the invitation's two branches are the last
* three describe blocks in this file.
*
* Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`,
* so `EmailService`'s ladder always resolved `en-US` and the localized rows
Expand DownExpand Up@@ -603,10 +622,10 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
expect(sent[0].locale).toBe('zh-CN');
});

it('the INVITATION send is untouched — its rung is #14641\'s', async () => {
// Scope fence, asserted rather than described: an invitee has no sys_user
// row until acceptance, so this send still names the deployment rung even
// when a row for that address would have carried a locale.
it('the INVITATION send reads the SAME rung, on the address — #14641', async () => {
// Was a scope fence ("untouched — its rung is #14641's") until #14641
// landed. The rung is the same one; only the predicate differs, because
// this callback is handed an address rather than a user row.
const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } };
const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
Expand All@@ -617,6 +636,215 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('es-ES');
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('es-ES');
});
});

// ── #14641 — the invitation send's two branches ────────────────────────────

/**
* A `sys_user` table keyed by ADDRESS, so the only thing separating the two
* branches is whether the invitee's address carries a row. One engine object
* is shared between drives wherever a test needs the branches to be provably
* the same lookup — otherwise "no row" and "no read" would be indistinguishable
* from the outside, since both land on the deployment default.
*/
function emailKeyedEngine(rows: Record<string, unknown>) {
const reads: any[] = [];
return {
reads,
engine: {
async findOne(object: string, query: any) {
reads.push({ object, query });
if (object !== 'sys_user') return null;
const email = (query?.where ?? {}).email as string;
return Object.prototype.hasOwnProperty.call(rows, email)
? { locale: rows[email] }
: null;
},
},
};
}

async function driveInvitation(opts: {
engine: unknown;
deployment?: string;
invitee?: string;
/** The INVITER's browser language — better-auth hands this callback its request. */
header?: string;
}) {
const { capturedConfig, sent } = await boot(opts.deployment, {
dataEngine: opts.engine,
} as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail(
{
email: opts.invitee ?? 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
},
opts.header === undefined
? undefined
: new Request('http://x/invite', { headers: { 'accept-language': opts.header } }),
);
return sent;
}

describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () => {
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
beforeEach(() => {
vi.clearAllMocks();
process.env.OS_MCP_SERVER_ENABLED = 'false';
});
afterEach(() => {
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
});

it('BRANCH 1 — an address that already has a row is written in THAT locale', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('zh-CN');
// The direction that makes the pin real: the deployment's own tag is NOT
// what went out.
expect(sent[0].locale).not.toBe('en-US');
});

it('and the reverse — an en-US invitee on a zh-CN deployment gets English', async () => {
// Swapping the two tags is what rules out a pin that would pass because
// one of them always wins.
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'en-US' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN' });
expect(sent[0].locale).toBe('en-US');
expect(sent[0].locale).not.toBe('zh-CN');
});

it('BRANCH 2 — a genuinely new invitee, no row, still takes the deployment default', async () => {
// ⚠️ Positive control for the zero, and the reason ONE engine drives both
// sends: the same table, the same predicate and the same deployment answer
// zh-CN for an address that carries a row and en-US for one that does not.
// That is what separates "the read ran and found nothing" from "the read
// never ran" / "this engine answers nothing" — both of which would also
// land on the deployment default and look identical from the payload.
const { engine, reads } = emailKeyedEngine({ 'known@example.com': 'zh-CN' });

const known = await driveInvitation({ engine, deployment: 'en-US', invitee: 'known@example.com' });
expect(known[0].locale).toBe('zh-CN');

const newcomer = await driveInvitation({ engine, deployment: 'en-US', invitee: 'newcomer@example.com' });
expect(newcomer[0].locale).toBe('en-US');
expect(newcomer[0].locale).not.toBe('zh-CN');

// ...and the newcomer's read really was attempted, on their address.
const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads.map((r) => r.query.where)).toEqual([
{ email: 'known@example.com' },
{ email: 'newcomer@example.com' },
]);
});

it("reads the column off the INVITEE's address — never the inviter's", async () => {
// Establishes WHICH rung produced the value, and on WHOSE identity. The
// inviter has a row too, carrying a different language; it must not be
// reached at all.
const { engine, reads } = emailKeyedEngine({
'invitee@example.com': 'zh-CN',
'dana@example.com': 'ja-JP',
});
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('ja-JP');

const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads).toHaveLength(1);
expect(userReads[0].query.where).toEqual({ email: 'invitee@example.com' });
expect(userReads[0].query.fields).toEqual(['locale']);
expect(userReads[0].query.context?.isSystem).toBe(true);
});

it("⛔ the INVITER's Accept-Language still loses — with the top rung now wired", async () => {
// The #14319 abstention, re-pinned in its stronger form: this send reads a
// recipient rung now, so "no request argument" is no longer trivially true
// of the whole callback. An English-speaking admin must still not force
// English onto a Chinese workspace's new hire.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('en-US');
});

it("...and does not win over the invitee's stored column either", async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'ja-JP' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('en-US');
});

it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => {
for (const junk of ['undefined', 'null', '', ' ', 42, {}]) {
const { engine } = emailKeyedEngine({ 'invitee@example.com': junk });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('en-US');
}
});

it('a failing recipient read never blocks the invitation', async () => {
const engine = { async findOne() { throw new Error('sys_user unavailable'); } };
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent).toHaveLength(1);
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('en-US');
});

it('with no data engine at all, the deployment rung answers exactly as before', async () => {
const { capturedConfig, sent } = await boot('en-US');
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail({
email: 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].locale).toBe('en-US');
});

it('with neither a row nor a deployment default, NO locale is named at all', async () => {
// The ladder's contract is written against an ABSENT key.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine });
expect(sent[0].locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false);
});

it('does not disturb the rest of the invitation payload', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].to).toBe('invitee@example.com');
expect(sent[0].relatedObject).toBe('sys_invitation');
expect(sent[0].relatedId).toBe('inv1');
expect(sent[0].organizationId).toBe('o1');
expect(sent[0].data.organization.name).toBe('Northwind');
expect(sent[0].data.role).toBe('member');
});

it('a placeholder address is still refused BEFORE any recipient read', async () => {
// #2766 V1.5 ordering, re-pinned now that a read sits on this path: the
// refusal must not be preceded by a lookup for an address that is not a
// real recipient.
const { engine, reads } = emailKeyedEngine({});
const { capturedConfig } = await boot('en-US', { dataEngine: engine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await expect(
org._opts.sendInvitationEmail({
email: 'u-abcdefghijklmnopqrst@placeholder.invalid',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
}),
).rejects.toThrow(/placeholder address/);
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
});
});
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
64 changes: 64 additions & 0 deletions .changeset/invitation-invitee-stored-locale.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/plugin-auth": patch
---

fix(auth): an invitation is written in the invitee's own `sys_user.locale` when the address already holds a row, and keeps the deployment default when it does not (#14641)

The four auth sends whose requester IS the recipient gained a per-recipient
language rung in #14762 (`sys_user.locale`, ruled on #13881). The two
**invitation** sends did not, and the recorded reason was structural rather
than an oversight: an invitee generally has no `sys_user` row until they accept,
so there is no stored language to read, and the *inviter's* `Accept-Language` is
the wrong authority — an English-speaking admin would silently send English
invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation reaches. This
change gives both invitation sends the same top rung the other four already
read, on a **two-branch** shape:

1. the address (or phone number) **already carries** a `sys_user` row whose
`locale` is set — an existing platform user invited into a second
organization, or a re-invitation — that row's `locale` wins;
2. a genuinely **new** invitee with **no** row keeps the deployment default,
because their language is still truly unknown at invitation time. So does an
invitee whose row exists but names no language: an unset column is not a
choice.

⛔ The inviter direction stays rejected on both branches, and is now pinned
against a manager that has the top rung wired rather than against one with no
rung at all. #13881's ruling item 3 fixes the chain as **recipient** locale →
deployment default; what opened here is the invitee's own column, never the
inviter's header.

**Both branches are reachable, measured rather than assumed.**
`sendInvitationEmail`: better-auth's `create-invitation` route rejects only an
address that is already a member of *this* organization
(`USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION`, `routes/crud-invites.mjs` in
the installed 1.7.2), so an existing account invited elsewhere — and the
`resend` branch — reach the callback normally. `sendPhoneInviteSms` reaches a row by
construction: its one in-repo caller, the identity import endpoint's `invite`
policy, **creates** the account and only then sends the SMS.

⚠️ **What the SMS path yields today, stated precisely, because a changeset
becomes release notes.** The rung is wired there and reads the row whenever the
row carries a locale — but `admin-import-users.ts` never writes `locale` (0
occurrences; positive control: `sendInviteSms` appears twice in the same file),
and `sys_user.locale` declares no column default. So on the only in-repo caller
the column is empty at send time and the invitation SMS still resolves to the
**deployment default** — the pre-change behaviour, unchanged for that flow. What
this buys on that surface is the rung itself: an out-of-repo caller, or a future
import that populates `locale`, is read rather than ignored. The behaviour users
see change today is on the invitation **email**.

**Matching is exact, and that is safe rather than merely tolerable here.**
better-auth lowercases the invitee address on the invite route and the stored
`user.email` on sign-up, so both sides of the predicate are already in the same
case; `email` and `phone_number` are both `unique: true` in the `user` table
`sys_user` is backed by. An address that resolves no row lands on the deployment
default, which is the documented floor rather than a failure — and, as
everywhere else on this ladder, a failing recipient read never blocks a send.

**Docs.** `permissions/authentication.mdx` said "The **invitation** SMS reads
the deployment default alone"; that sentence is now false and is corrected. No
shipped page states the invitation *email* locale rule (the auth email ladder is
undocumented as a whole), so nothing else moved.
5 changes: 3 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,11 +444,12 @@ The OTP and invitation bodies are localised and tenant-customisable: a
`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite,
channel 'sms', locale)` wins — built-in English and Chinese rows are seeded
once (never overwriting your edits) and can be changed under Setup →
Notification Templates. For the **OTP** the locale is the recipient's own
Notification Templates. Both bodies resolve the same way: the recipient's own
`sys_user.locale` when their account has one, and the deployment default
(`localization.locale` setting) otherwise — the account is matched on its
`phone_number`, so a number no account carries takes the deployment default
too. The **invitation** SMS reads the deployment default alone. Whichever
too. For the **invitation** SMS the account normally does exist, because the
identity import endpoint creates it and only then sends the message. Whichever
locale that names is then resolved with a `zh-CN → zh → en` fallback chain;
holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`,
`{{loginUrl}}` (invitation — `{{baseUrl}}`, the bare origin, is still
Expand Down
250 changes: 239 additions & 11 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,12 +10,31 @@
* 2026-08-13 ruling had made the deployment default the whole answer and
* rejected `Accept-Language` outright. #14762 then added the rung ABOVE both,
* per the #14788 option-D ruling of 2026-09-03: the recipient's own
* `sys_user.locale` (#13881) when the account holds one. Invitations keep the
* deployment rung — an invitee has no row until acceptance (#14641) — and
* this file pins that abstention too. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` /
* `emailLocaleArg`; the request rung's own cases and the stored rung's are the
* last two describe blocks in this file.
* `sys_user.locale` (#13881) when the account holds one.
*
* #14641 reached the INVITATION send last, and it is the one send with two
* branches rather than one. The card's terminal state read "choose the
* template by the invitee's stored language", which cannot hold for every
* invitee — an invitee generally has no `sys_user` row until acceptance, so
* there is no stored language to read. What IS implementable, and what this
* file pins, is the two-branch shape:
*
* 1. the address ALREADY carries a `sys_user` row — an existing platform
* user invited into a second organization, or a re-invitation → their own
* `locale`;
* 2. a genuinely new invitee with NO row → the deployment default, because
* their language is still truly unknown at invitation time.
*
* ⛔ The INVITER direction stays rejected on both branches: #13881's ruling
* item 3 fixes the chain as RECIPIENT locale → deployment default, and
* stamping the inviter's `Accept-Language` onto the invitee's mail would move
* the defect one seat over. That abstention is pinned here too, now against a
* manager that HAS the top rung wired — the stronger form of the #14319 pin.
*
* The ruling text of record lives on `AuthManager.setDefaultEmailLocale` /
* `authEmailLocaleFromRequest` / `emailLocaleArg`; the request rung's own
* cases, the stored rung's, and the invitation's two branches are the last
* three describe blocks in this file.
*
* Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`,
* so `EmailService`'s ladder always resolved `en-US` and the localized rows
Expand DownExpand Up@@ -603,10 +622,10 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
expect(sent[0].locale).toBe('zh-CN');
});

it('the INVITATION send is untouched — its rung is #14641\'s', async () => {
// Scope fence, asserted rather than described: an invitee has no sys_user
// row until acceptance, so this send still names the deployment rung even
// when a row for that address would have carried a locale.
it('the INVITATION send reads the SAME rung, on the address — #14641', async () => {
// Was a scope fence ("untouched — its rung is #14641's") until #14641
// landed. The rung is the same one; only the predicate differs, because
// this callback is handed an address rather than a user row.
const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } };
const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
Expand All@@ -617,6 +636,215 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('es-ES');
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('es-ES');
});
});

// ── #14641 — the invitation send's two branches ────────────────────────────

/**
* A `sys_user` table keyed by ADDRESS, so the only thing separating the two
* branches is whether the invitee's address carries a row. One engine object
* is shared between drives wherever a test needs the branches to be provably
* the same lookup — otherwise "no row" and "no read" would be indistinguishable
* from the outside, since both land on the deployment default.
*/
function emailKeyedEngine(rows: Record<string, unknown>) {
const reads: any[] = [];
return {
reads,
engine: {
async findOne(object: string, query: any) {
reads.push({ object, query });
if (object !== 'sys_user') return null;
const email = (query?.where ?? {}).email as string;
return Object.prototype.hasOwnProperty.call(rows, email)
? { locale: rows[email] }
: null;
},
},
};
}

async function driveInvitation(opts: {
engine: unknown;
deployment?: string;
invitee?: string;
/** The INVITER's browser language — better-auth hands this callback its request. */
header?: string;
}) {
const { capturedConfig, sent } = await boot(opts.deployment, {
dataEngine: opts.engine,
} as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail(
{
email: opts.invitee ?? 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
},
opts.header === undefined
? undefined
: new Request('http://x/invite', { headers: { 'accept-language': opts.header } }),
);
return sent;
}

describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () => {
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
beforeEach(() => {
vi.clearAllMocks();
process.env.OS_MCP_SERVER_ENABLED = 'false';
});
afterEach(() => {
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
});

it('BRANCH 1 — an address that already has a row is written in THAT locale', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('zh-CN');
// The direction that makes the pin real: the deployment's own tag is NOT
// what went out.
expect(sent[0].locale).not.toBe('en-US');
});

it('and the reverse — an en-US invitee on a zh-CN deployment gets English', async () => {
// Swapping the two tags is what rules out a pin that would pass because
// one of them always wins.
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'en-US' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN' });
expect(sent[0].locale).toBe('en-US');
expect(sent[0].locale).not.toBe('zh-CN');
});

it('BRANCH 2 — a genuinely new invitee, no row, still takes the deployment default', async () => {
// ⚠️ Positive control for the zero, and the reason ONE engine drives both
// sends: the same table, the same predicate and the same deployment answer
// zh-CN for an address that carries a row and en-US for one that does not.
// That is what separates "the read ran and found nothing" from "the read
// never ran" / "this engine answers nothing" — both of which would also
// land on the deployment default and look identical from the payload.
const { engine, reads } = emailKeyedEngine({ 'known@example.com': 'zh-CN' });

const known = await driveInvitation({ engine, deployment: 'en-US', invitee: 'known@example.com' });
expect(known[0].locale).toBe('zh-CN');

const newcomer = await driveInvitation({ engine, deployment: 'en-US', invitee: 'newcomer@example.com' });
expect(newcomer[0].locale).toBe('en-US');
expect(newcomer[0].locale).not.toBe('zh-CN');

// ...and the newcomer's read really was attempted, on their address.
const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads.map((r) => r.query.where)).toEqual([
{ email: 'known@example.com' },
{ email: 'newcomer@example.com' },
]);
});

it("reads the column off the INVITEE's address — never the inviter's", async () => {
// Establishes WHICH rung produced the value, and on WHOSE identity. The
// inviter has a row too, carrying a different language; it must not be
// reached at all.
const { engine, reads } = emailKeyedEngine({
'invitee@example.com': 'zh-CN',
'dana@example.com': 'ja-JP',
});
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('ja-JP');

const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads).toHaveLength(1);
expect(userReads[0].query.where).toEqual({ email: 'invitee@example.com' });
expect(userReads[0].query.fields).toEqual(['locale']);
expect(userReads[0].query.context?.isSystem).toBe(true);
});

it("⛔ the INVITER's Accept-Language still loses — with the top rung now wired", async () => {
// The #14319 abstention, re-pinned in its stronger form: this send reads a
// recipient rung now, so "no request argument" is no longer trivially true
// of the whole callback. An English-speaking admin must still not force
// English onto a Chinese workspace's new hire.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('en-US');
});

it("...and does not win over the invitee's stored column either", async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'ja-JP' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('en-US');
});

it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => {
for (const junk of ['undefined', 'null', '', ' ', 42, {}]) {
const { engine } = emailKeyedEngine({ 'invitee@example.com': junk });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('en-US');
}
});

it('a failing recipient read never blocks the invitation', async () => {
const engine = { async findOne() { throw new Error('sys_user unavailable'); } };
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent).toHaveLength(1);
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('en-US');
});

it('with no data engine at all, the deployment rung answers exactly as before', async () => {
const { capturedConfig, sent } = await boot('en-US');
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail({
email: 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].locale).toBe('en-US');
});

it('with neither a row nor a deployment default, NO locale is named at all', async () => {
// The ladder's contract is written against an ABSENT key.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine });
expect(sent[0].locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false);
});

it('does not disturb the rest of the invitation payload', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].to).toBe('invitee@example.com');
expect(sent[0].relatedObject).toBe('sys_invitation');
expect(sent[0].relatedId).toBe('inv1');
expect(sent[0].organizationId).toBe('o1');
expect(sent[0].data.organization.name).toBe('Northwind');
expect(sent[0].data.role).toBe('member');
});

it('a placeholder address is still refused BEFORE any recipient read', async () => {
// #2766 V1.5 ordering, re-pinned now that a read sits on this path: the
// refusal must not be preceded by a lookup for an address that is not a
// real recipient.
const { engine, reads } = emailKeyedEngine({});
const { capturedConfig } = await boot('en-US', { dataEngine: engine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await expect(
org._opts.sendInvitationEmail({
email: 'u-abcdefghijklmnopqrst@placeholder.invalid',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
}),
).rejects.toThrow(/placeholder address/);
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
});
});
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
64 changes: 64 additions & 0 deletions .changeset/invitation-invitee-stored-locale.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/plugin-auth": patch
---

fix(auth): an invitation is written in the invitee's own `sys_user.locale` when the address already holds a row, and keeps the deployment default when it does not (#14641)

The four auth sends whose requester IS the recipient gained a per-recipient
language rung in #14762 (`sys_user.locale`, ruled on #13881). The two
**invitation** sends did not, and the recorded reason was structural rather
than an oversight: an invitee generally has no `sys_user` row until they accept,
so there is no stored language to read, and the *inviter's* `Accept-Language` is
the wrong authority — an English-speaking admin would silently send English
invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation reaches. This
change gives both invitation sends the same top rung the other four already
read, on a **two-branch** shape:

1. the address (or phone number) **already carries** a `sys_user` row whose
`locale` is set — an existing platform user invited into a second
organization, or a re-invitation — that row's `locale` wins;
2. a genuinely **new** invitee with **no** row keeps the deployment default,
because their language is still truly unknown at invitation time. So does an
invitee whose row exists but names no language: an unset column is not a
choice.

⛔ The inviter direction stays rejected on both branches, and is now pinned
against a manager that has the top rung wired rather than against one with no
rung at all. #13881's ruling item 3 fixes the chain as **recipient** locale →
deployment default; what opened here is the invitee's own column, never the
inviter's header.

**Both branches are reachable, measured rather than assumed.**
`sendInvitationEmail`: better-auth's `create-invitation` route rejects only an
address that is already a member of *this* organization
(`USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION`, `routes/crud-invites.mjs` in
the installed 1.7.2), so an existing account invited elsewhere — and the
`resend` branch — reach the callback normally. `sendPhoneInviteSms` reaches a row by
construction: its one in-repo caller, the identity import endpoint's `invite`
policy, **creates** the account and only then sends the SMS.

⚠️ **What the SMS path yields today, stated precisely, because a changeset
becomes release notes.** The rung is wired there and reads the row whenever the
row carries a locale — but `admin-import-users.ts` never writes `locale` (0
occurrences; positive control: `sendInviteSms` appears twice in the same file),
and `sys_user.locale` declares no column default. So on the only in-repo caller
the column is empty at send time and the invitation SMS still resolves to the
**deployment default** — the pre-change behaviour, unchanged for that flow. What
this buys on that surface is the rung itself: an out-of-repo caller, or a future
import that populates `locale`, is read rather than ignored. The behaviour users
see change today is on the invitation **email**.

**Matching is exact, and that is safe rather than merely tolerable here.**
better-auth lowercases the invitee address on the invite route and the stored
`user.email` on sign-up, so both sides of the predicate are already in the same
case; `email` and `phone_number` are both `unique: true` in the `user` table
`sys_user` is backed by. An address that resolves no row lands on the deployment
default, which is the documented floor rather than a failure — and, as
everywhere else on this ladder, a failing recipient read never blocks a send.

**Docs.** `permissions/authentication.mdx` said "The **invitation** SMS reads
the deployment default alone"; that sentence is now false and is corrected. No
shipped page states the invitation *email* locale rule (the auth email ladder is
undocumented as a whole), so nothing else moved.
5 changes: 3 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,11 +444,12 @@ The OTP and invitation bodies are localised and tenant-customisable: a
`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite,
channel 'sms', locale)` wins — built-in English and Chinese rows are seeded
once (never overwriting your edits) and can be changed under Setup →
Notification Templates. For the **OTP** the locale is the recipient's own
Notification Templates. Both bodies resolve the same way: the recipient's own
`sys_user.locale` when their account has one, and the deployment default
(`localization.locale` setting) otherwise — the account is matched on its
`phone_number`, so a number no account carries takes the deployment default
too. The **invitation** SMS reads the deployment default alone. Whichever
too. For the **invitation** SMS the account normally does exist, because the
identity import endpoint creates it and only then sends the message. Whichever
locale that names is then resolved with a `zh-CN → zh → en` fallback chain;
holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`,
`{{loginUrl}}` (invitation — `{{baseUrl}}`, the bare origin, is still
Expand Down
250 changes: 239 additions & 11 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,12 +10,31 @@
* 2026-08-13 ruling had made the deployment default the whole answer and
* rejected `Accept-Language` outright. #14762 then added the rung ABOVE both,
* per the #14788 option-D ruling of 2026-09-03: the recipient's own
* `sys_user.locale` (#13881) when the account holds one. Invitations keep the
* deployment rung — an invitee has no row until acceptance (#14641) — and
* this file pins that abstention too. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` /
* `emailLocaleArg`; the request rung's own cases and the stored rung's are the
* last two describe blocks in this file.
* `sys_user.locale` (#13881) when the account holds one.
*
* #14641 reached the INVITATION send last, and it is the one send with two
* branches rather than one. The card's terminal state read "choose the
* template by the invitee's stored language", which cannot hold for every
* invitee — an invitee generally has no `sys_user` row until acceptance, so
* there is no stored language to read. What IS implementable, and what this
* file pins, is the two-branch shape:
*
* 1. the address ALREADY carries a `sys_user` row — an existing platform
* user invited into a second organization, or a re-invitation → their own
* `locale`;
* 2. a genuinely new invitee with NO row → the deployment default, because
* their language is still truly unknown at invitation time.
*
* ⛔ The INVITER direction stays rejected on both branches: #13881's ruling
* item 3 fixes the chain as RECIPIENT locale → deployment default, and
* stamping the inviter's `Accept-Language` onto the invitee's mail would move
* the defect one seat over. That abstention is pinned here too, now against a
* manager that HAS the top rung wired — the stronger form of the #14319 pin.
*
* The ruling text of record lives on `AuthManager.setDefaultEmailLocale` /
* `authEmailLocaleFromRequest` / `emailLocaleArg`; the request rung's own
* cases, the stored rung's, and the invitation's two branches are the last
* three describe blocks in this file.
*
* Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`,
* so `EmailService`'s ladder always resolved `en-US` and the localized rows
Expand DownExpand Up@@ -603,10 +622,10 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
expect(sent[0].locale).toBe('zh-CN');
});

it('the INVITATION send is untouched — its rung is #14641\'s', async () => {
// Scope fence, asserted rather than described: an invitee has no sys_user
// row until acceptance, so this send still names the deployment rung even
// when a row for that address would have carried a locale.
it('the INVITATION send reads the SAME rung, on the address — #14641', async () => {
// Was a scope fence ("untouched — its rung is #14641's") until #14641
// landed. The rung is the same one; only the predicate differs, because
// this callback is handed an address rather than a user row.
const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } };
const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
Expand All@@ -617,6 +636,215 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('es-ES');
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('es-ES');
});
});

// ── #14641 — the invitation send's two branches ────────────────────────────

/**
* A `sys_user` table keyed by ADDRESS, so the only thing separating the two
* branches is whether the invitee's address carries a row. One engine object
* is shared between drives wherever a test needs the branches to be provably
* the same lookup — otherwise "no row" and "no read" would be indistinguishable
* from the outside, since both land on the deployment default.
*/
function emailKeyedEngine(rows: Record<string, unknown>) {
const reads: any[] = [];
return {
reads,
engine: {
async findOne(object: string, query: any) {
reads.push({ object, query });
if (object !== 'sys_user') return null;
const email = (query?.where ?? {}).email as string;
return Object.prototype.hasOwnProperty.call(rows, email)
? { locale: rows[email] }
: null;
},
},
};
}

async function driveInvitation(opts: {
engine: unknown;
deployment?: string;
invitee?: string;
/** The INVITER's browser language — better-auth hands this callback its request. */
header?: string;
}) {
const { capturedConfig, sent } = await boot(opts.deployment, {
dataEngine: opts.engine,
} as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail(
{
email: opts.invitee ?? 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
},
opts.header === undefined
? undefined
: new Request('http://x/invite', { headers: { 'accept-language': opts.header } }),
);
return sent;
}

describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () => {
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
beforeEach(() => {
vi.clearAllMocks();
process.env.OS_MCP_SERVER_ENABLED = 'false';
});
afterEach(() => {
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
});

it('BRANCH 1 — an address that already has a row is written in THAT locale', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('zh-CN');
// The direction that makes the pin real: the deployment's own tag is NOT
// what went out.
expect(sent[0].locale).not.toBe('en-US');
});

it('and the reverse — an en-US invitee on a zh-CN deployment gets English', async () => {
// Swapping the two tags is what rules out a pin that would pass because
// one of them always wins.
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'en-US' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN' });
expect(sent[0].locale).toBe('en-US');
expect(sent[0].locale).not.toBe('zh-CN');
});

it('BRANCH 2 — a genuinely new invitee, no row, still takes the deployment default', async () => {
// ⚠️ Positive control for the zero, and the reason ONE engine drives both
// sends: the same table, the same predicate and the same deployment answer
// zh-CN for an address that carries a row and en-US for one that does not.
// That is what separates "the read ran and found nothing" from "the read
// never ran" / "this engine answers nothing" — both of which would also
// land on the deployment default and look identical from the payload.
const { engine, reads } = emailKeyedEngine({ 'known@example.com': 'zh-CN' });

const known = await driveInvitation({ engine, deployment: 'en-US', invitee: 'known@example.com' });
expect(known[0].locale).toBe('zh-CN');

const newcomer = await driveInvitation({ engine, deployment: 'en-US', invitee: 'newcomer@example.com' });
expect(newcomer[0].locale).toBe('en-US');
expect(newcomer[0].locale).not.toBe('zh-CN');

// ...and the newcomer's read really was attempted, on their address.
const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads.map((r) => r.query.where)).toEqual([
{ email: 'known@example.com' },
{ email: 'newcomer@example.com' },
]);
});

it("reads the column off the INVITEE's address — never the inviter's", async () => {
// Establishes WHICH rung produced the value, and on WHOSE identity. The
// inviter has a row too, carrying a different language; it must not be
// reached at all.
const { engine, reads } = emailKeyedEngine({
'invitee@example.com': 'zh-CN',
'dana@example.com': 'ja-JP',
});
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('ja-JP');

const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads).toHaveLength(1);
expect(userReads[0].query.where).toEqual({ email: 'invitee@example.com' });
expect(userReads[0].query.fields).toEqual(['locale']);
expect(userReads[0].query.context?.isSystem).toBe(true);
});

it("⛔ the INVITER's Accept-Language still loses — with the top rung now wired", async () => {
// The #14319 abstention, re-pinned in its stronger form: this send reads a
// recipient rung now, so "no request argument" is no longer trivially true
// of the whole callback. An English-speaking admin must still not force
// English onto a Chinese workspace's new hire.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('en-US');
});

it("...and does not win over the invitee's stored column either", async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'ja-JP' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('en-US');
});

it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => {
for (const junk of ['undefined', 'null', '', ' ', 42, {}]) {
const { engine } = emailKeyedEngine({ 'invitee@example.com': junk });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('en-US');
}
});

it('a failing recipient read never blocks the invitation', async () => {
const engine = { async findOne() { throw new Error('sys_user unavailable'); } };
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent).toHaveLength(1);
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('en-US');
});

it('with no data engine at all, the deployment rung answers exactly as before', async () => {
const { capturedConfig, sent } = await boot('en-US');
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail({
email: 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].locale).toBe('en-US');
});

it('with neither a row nor a deployment default, NO locale is named at all', async () => {
// The ladder's contract is written against an ABSENT key.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine });
expect(sent[0].locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false);
});

it('does not disturb the rest of the invitation payload', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].to).toBe('invitee@example.com');
expect(sent[0].relatedObject).toBe('sys_invitation');
expect(sent[0].relatedId).toBe('inv1');
expect(sent[0].organizationId).toBe('o1');
expect(sent[0].data.organization.name).toBe('Northwind');
expect(sent[0].data.role).toBe('member');
});

it('a placeholder address is still refused BEFORE any recipient read', async () => {
// #2766 V1.5 ordering, re-pinned now that a read sits on this path: the
// refusal must not be preceded by a lookup for an address that is not a
// real recipient.
const { engine, reads } = emailKeyedEngine({});
const { capturedConfig } = await boot('en-US', { dataEngine: engine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await expect(
org._opts.sendInvitationEmail({
email: 'u-abcdefghijklmnopqrst@placeholder.invalid',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
}),
).rejects.toThrow(/placeholder address/);
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
});
});
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
64 changes: 64 additions & 0 deletions .changeset/invitation-invitee-stored-locale.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/plugin-auth": patch
---

fix(auth): an invitation is written in the invitee's own `sys_user.locale` when the address already holds a row, and keeps the deployment default when it does not (#14641)

The four auth sends whose requester IS the recipient gained a per-recipient
language rung in #14762 (`sys_user.locale`, ruled on #13881). The two
**invitation** sends did not, and the recorded reason was structural rather
than an oversight: an invitee generally has no `sys_user` row until they accept,
so there is no stored language to read, and the *inviter's* `Accept-Language` is
the wrong authority — an English-speaking admin would silently send English
invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation reaches. This
change gives both invitation sends the same top rung the other four already
read, on a **two-branch** shape:

1. the address (or phone number) **already carries** a `sys_user` row whose
`locale` is set — an existing platform user invited into a second
organization, or a re-invitation — that row's `locale` wins;
2. a genuinely **new** invitee with **no** row keeps the deployment default,
because their language is still truly unknown at invitation time. So does an
invitee whose row exists but names no language: an unset column is not a
choice.

⛔ The inviter direction stays rejected on both branches, and is now pinned
against a manager that has the top rung wired rather than against one with no
rung at all. #13881's ruling item 3 fixes the chain as **recipient** locale →
deployment default; what opened here is the invitee's own column, never the
inviter's header.

**Both branches are reachable, measured rather than assumed.**
`sendInvitationEmail`: better-auth's `create-invitation` route rejects only an
address that is already a member of *this* organization
(`USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION`, `routes/crud-invites.mjs` in
the installed 1.7.2), so an existing account invited elsewhere — and the
`resend` branch — reach the callback normally. `sendPhoneInviteSms` reaches a row by
construction: its one in-repo caller, the identity import endpoint's `invite`
policy, **creates** the account and only then sends the SMS.

⚠️ **What the SMS path yields today, stated precisely, because a changeset
becomes release notes.** The rung is wired there and reads the row whenever the
row carries a locale — but `admin-import-users.ts` never writes `locale` (0
occurrences; positive control: `sendInviteSms` appears twice in the same file),
and `sys_user.locale` declares no column default. So on the only in-repo caller
the column is empty at send time and the invitation SMS still resolves to the
**deployment default** — the pre-change behaviour, unchanged for that flow. What
this buys on that surface is the rung itself: an out-of-repo caller, or a future
import that populates `locale`, is read rather than ignored. The behaviour users
see change today is on the invitation **email**.

**Matching is exact, and that is safe rather than merely tolerable here.**
better-auth lowercases the invitee address on the invite route and the stored
`user.email` on sign-up, so both sides of the predicate are already in the same
case; `email` and `phone_number` are both `unique: true` in the `user` table
`sys_user` is backed by. An address that resolves no row lands on the deployment
default, which is the documented floor rather than a failure — and, as
everywhere else on this ladder, a failing recipient read never blocks a send.

**Docs.** `permissions/authentication.mdx` said "The **invitation** SMS reads
the deployment default alone"; that sentence is now false and is corrected. No
shipped page states the invitation *email* locale rule (the auth email ladder is
undocumented as a whole), so nothing else moved.
5 changes: 3 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,11 +444,12 @@ The OTP and invitation bodies are localised and tenant-customisable: a
`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite,
channel 'sms', locale)` wins — built-in English and Chinese rows are seeded
once (never overwriting your edits) and can be changed under Setup →
Notification Templates. For the **OTP** the locale is the recipient's own
Notification Templates. Both bodies resolve the same way: the recipient's own
`sys_user.locale` when their account has one, and the deployment default
(`localization.locale` setting) otherwise — the account is matched on its
`phone_number`, so a number no account carries takes the deployment default
too. The **invitation** SMS reads the deployment default alone. Whichever
too. For the **invitation** SMS the account normally does exist, because the
identity import endpoint creates it and only then sends the message. Whichever
locale that names is then resolved with a `zh-CN → zh → en` fallback chain;
holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`,
`{{loginUrl}}` (invitation — `{{baseUrl}}`, the bare origin, is still
Expand Down
250 changes: 239 additions & 11 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,12 +10,31 @@
* 2026-08-13 ruling had made the deployment default the whole answer and
* rejected `Accept-Language` outright. #14762 then added the rung ABOVE both,
* per the #14788 option-D ruling of 2026-09-03: the recipient's own
* `sys_user.locale` (#13881) when the account holds one. Invitations keep the
* deployment rung — an invitee has no row until acceptance (#14641) — and
* this file pins that abstention too. The ruling text of record lives on
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` /
* `emailLocaleArg`; the request rung's own cases and the stored rung's are the
* last two describe blocks in this file.
* `sys_user.locale` (#13881) when the account holds one.
*
* #14641 reached the INVITATION send last, and it is the one send with two
* branches rather than one. The card's terminal state read "choose the
* template by the invitee's stored language", which cannot hold for every
* invitee — an invitee generally has no `sys_user` row until acceptance, so
* there is no stored language to read. What IS implementable, and what this
* file pins, is the two-branch shape:
*
* 1. the address ALREADY carries a `sys_user` row — an existing platform
* user invited into a second organization, or a re-invitation → their own
* `locale`;
* 2. a genuinely new invitee with NO row → the deployment default, because
* their language is still truly unknown at invitation time.
*
* ⛔ The INVITER direction stays rejected on both branches: #13881's ruling
* item 3 fixes the chain as RECIPIENT locale → deployment default, and
* stamping the inviter's `Accept-Language` onto the invitee's mail would move
* the defect one seat over. That abstention is pinned here too, now against a
* manager that HAS the top rung wired — the stronger form of the #14319 pin.
*
* The ruling text of record lives on `AuthManager.setDefaultEmailLocale` /
* `authEmailLocaleFromRequest` / `emailLocaleArg`; the request rung's own
* cases, the stored rung's, and the invitation's two branches are the last
* three describe blocks in this file.
*
* Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`,
* so `EmailService`'s ladder always resolved `en-US` and the localized rows
Expand DownExpand Up@@ -603,10 +622,10 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
expect(sent[0].locale).toBe('zh-CN');
});

it('the INVITATION send is untouched — its rung is #14641\'s', async () => {
// Scope fence, asserted rather than described: an invitee has no sys_user
// row until acceptance, so this send still names the deployment rung even
// when a row for that address would have carried a locale.
it('the INVITATION send reads the SAME rung, on the address — #14641', async () => {
// Was a scope fence ("untouched — its rung is #14641's") until #14641
// landed. The rung is the same one; only the predicate differs, because
// this callback is handed an address rather than a user row.
const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } };
const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
Expand All@@ -617,6 +636,215 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('es-ES');
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('es-ES');
});
});

// ── #14641 — the invitation send's two branches ────────────────────────────

/**
* A `sys_user` table keyed by ADDRESS, so the only thing separating the two
* branches is whether the invitee's address carries a row. One engine object
* is shared between drives wherever a test needs the branches to be provably
* the same lookup — otherwise "no row" and "no read" would be indistinguishable
* from the outside, since both land on the deployment default.
*/
function emailKeyedEngine(rows: Record<string, unknown>) {
const reads: any[] = [];
return {
reads,
engine: {
async findOne(object: string, query: any) {
reads.push({ object, query });
if (object !== 'sys_user') return null;
const email = (query?.where ?? {}).email as string;
return Object.prototype.hasOwnProperty.call(rows, email)
? { locale: rows[email] }
: null;
},
},
};
}

async function driveInvitation(opts: {
engine: unknown;
deployment?: string;
invitee?: string;
/** The INVITER's browser language — better-auth hands this callback its request. */
header?: string;
}) {
const { capturedConfig, sent } = await boot(opts.deployment, {
dataEngine: opts.engine,
} as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail(
{
email: opts.invitee ?? 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
},
opts.header === undefined
? undefined
: new Request('http://x/invite', { headers: { 'accept-language': opts.header } }),
);
return sent;
}

describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () => {
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
beforeEach(() => {
vi.clearAllMocks();
process.env.OS_MCP_SERVER_ENABLED = 'false';
});
afterEach(() => {
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
});

it('BRANCH 1 — an address that already has a row is written in THAT locale', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('zh-CN');
// The direction that makes the pin real: the deployment's own tag is NOT
// what went out.
expect(sent[0].locale).not.toBe('en-US');
});

it('and the reverse — an en-US invitee on a zh-CN deployment gets English', async () => {
// Swapping the two tags is what rules out a pin that would pass because
// one of them always wins.
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'en-US' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN' });
expect(sent[0].locale).toBe('en-US');
expect(sent[0].locale).not.toBe('zh-CN');
});

it('BRANCH 2 — a genuinely new invitee, no row, still takes the deployment default', async () => {
// ⚠️ Positive control for the zero, and the reason ONE engine drives both
// sends: the same table, the same predicate and the same deployment answer
// zh-CN for an address that carries a row and en-US for one that does not.
// That is what separates "the read ran and found nothing" from "the read
// never ran" / "this engine answers nothing" — both of which would also
// land on the deployment default and look identical from the payload.
const { engine, reads } = emailKeyedEngine({ 'known@example.com': 'zh-CN' });

const known = await driveInvitation({ engine, deployment: 'en-US', invitee: 'known@example.com' });
expect(known[0].locale).toBe('zh-CN');

const newcomer = await driveInvitation({ engine, deployment: 'en-US', invitee: 'newcomer@example.com' });
expect(newcomer[0].locale).toBe('en-US');
expect(newcomer[0].locale).not.toBe('zh-CN');

// ...and the newcomer's read really was attempted, on their address.
const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads.map((r) => r.query.where)).toEqual([
{ email: 'known@example.com' },
{ email: 'newcomer@example.com' },
]);
});

it("reads the column off the INVITEE's address — never the inviter's", async () => {
// Establishes WHICH rung produced the value, and on WHOSE identity. The
// inviter has a row too, carrying a different language; it must not be
// reached at all.
const { engine, reads } = emailKeyedEngine({
'invitee@example.com': 'zh-CN',
'dana@example.com': 'ja-JP',
});
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('ja-JP');

const userReads = reads.filter((r) => r.object === 'sys_user');
expect(userReads).toHaveLength(1);
expect(userReads[0].query.where).toEqual({ email: 'invitee@example.com' });
expect(userReads[0].query.fields).toEqual(['locale']);
expect(userReads[0].query.context?.isSystem).toBe(true);
});

it("⛔ the INVITER's Accept-Language still loses — with the top rung now wired", async () => {
// The #14319 abstention, re-pinned in its stronger form: this send reads a
// recipient rung now, so "no request argument" is no longer trivially true
// of the whole callback. An English-speaking admin must still not force
// English onto a Chinese workspace's new hire.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('zh-CN');
expect(sent[0].locale).not.toBe('en-US');
});

it("...and does not win over the invitee's stored column either", async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'ja-JP' });
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
expect(sent[0].locale).toBe('ja-JP');
expect(sent[0].locale).not.toBe('en-US');
});

it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => {
for (const junk of ['undefined', 'null', '', ' ', 42, {}]) {
const { engine } = emailKeyedEngine({ 'invitee@example.com': junk });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('en-US');
}
});

it('a failing recipient read never blocks the invitation', async () => {
const engine = { async findOne() { throw new Error('sys_user unavailable'); } };
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent).toHaveLength(1);
expect(sent[0].template).toBe('auth.invitation');
expect(sent[0].locale).toBe('en-US');
});

it('with no data engine at all, the deployment rung answers exactly as before', async () => {
const { capturedConfig, sent } = await boot('en-US');
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await org._opts.sendInvitationEmail({
email: 'invitee@example.com',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
});
expect(sent[0].locale).toBe('en-US');
});

it('with neither a row nor a deployment default, NO locale is named at all', async () => {
// The ladder's contract is written against an ABSENT key.
const { engine } = emailKeyedEngine({});
const sent = await driveInvitation({ engine });
expect(sent[0].locale).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false);
});

it('does not disturb the rest of the invitation payload', async () => {
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
const sent = await driveInvitation({ engine, deployment: 'en-US' });
expect(sent[0].to).toBe('invitee@example.com');
expect(sent[0].relatedObject).toBe('sys_invitation');
expect(sent[0].relatedId).toBe('inv1');
expect(sent[0].organizationId).toBe('o1');
expect(sent[0].data.organization.name).toBe('Northwind');
expect(sent[0].data.role).toBe('member');
});

it('a placeholder address is still refused BEFORE any recipient read', async () => {
// #2766 V1.5 ordering, re-pinned now that a read sits on this path: the
// refusal must not be preceded by a lookup for an address that is not a
// real recipient.
const { engine, reads } = emailKeyedEngine({});
const { capturedConfig } = await boot('en-US', { dataEngine: engine } as never);
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
await expect(
org._opts.sendInvitationEmail({
email: 'u-abcdefghijklmnopqrst@placeholder.invalid',
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
organization: { name: 'Northwind' },
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
}),
).rejects.toThrow(/placeholder address/);
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
});
});
Loading
Loading