Skip to content

Commit f074616

Browse files
claude[bot]claude
andauthored
fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row (#15119)
* fix(auth): an invitation reads the invitee's own `sys_user.locale` when the address already holds a row The two invitation sends kept the deployment rung while the other four auth sends gained a per-recipient one in #14762. The recorded reason — an invitee has no `sys_user` row until acceptance — covers only one of the two populations an invitation reaches, so both sends now take a two-branch shape: 1. an address / phone number that ALREADY carries a row (an existing user invited into a second organization, a re-invitation, or an imported phone-only account) uses that row's `locale`; 2. a genuinely new invitee with no row keeps the deployment default. The inviter direction stays rejected on both branches, now pinned against a manager that HAS the top rung wired rather than one with no rung at all. Reachability of branch 1 is measured, not assumed: better-auth's `create-invitation` rejects only an address already a member of THIS org (`routes/crud-invites.mjs`, installed 1.7.2), and the SMS invite's one in-repo caller creates the account before it sends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(auth): state what the SMS invite rung yields today, and give its pins their own #14641 describe Two wording items from the Clause-2 contract review, which passed. No behaviour, assertion or fixture changes. 1. The changeset claimed an imported phone-only account's `locale` wins on the SMS invite path. Re-measured: `admin-import-users.ts` contains 0 occurrences of `locale` (positive control: `sendInviteSms` twice in the same file) and `sys_user.locale` declares no column default (positive control: `defaultValue` appears 15 times elsewhere in that object). So on the only in-repo caller the row exists but its column is empty at send time, and that flow still resolves to the deployment default — the pre-change behaviour. The rung is wired and answers for an out-of-repo caller or a future import that populates the column; a changeset becomes release notes, so it now says exactly that. The same overstatement is corrected in the three code comments carrying it (`sendPhoneInviteSms`, `setDefaultSmsLocale`, `phone-sms-texts.ts`). 2. The five SMS invitation pins were nested inside `describe('#14762 ...')`, so their reporter path credited the wrong card. They now sit in their own sibling describe naming #14641. Move only — no re-indentation, and no `expect`/`await`/`const` line in the diff. `content/docs/**` deliberately untouched: the review found the shipped sentence literally accurate as written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a548550 commit f074616

6 files changed

Lines changed: 510 additions & 44 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
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)
6+
7+
The four auth sends whose requester IS the recipient gained a per-recipient
8+
language rung in #14762 (`sys_user.locale`, ruled on #13881). The two
9+
**invitation** sends did not, and the recorded reason was structural rather
10+
than an oversight: an invitee generally has no `sys_user` row until they accept,
11+
so there is no stored language to read, and the *inviter's* `Accept-Language` is
12+
the wrong authority — an English-speaking admin would silently send English
13+
invitations to a Chinese-language workspace's new hires.
14+
15+
That reason covers only one of the two populations an invitation reaches. This
16+
change gives both invitation sends the same top rung the other four already
17+
read, on a **two-branch** shape:
18+
19+
1. the address (or phone number) **already carries** a `sys_user` row whose
20+
`locale` is set — an existing platform user invited into a second
21+
organization, or a re-invitation — that row's `locale` wins;
22+
2. a genuinely **new** invitee with **no** row keeps the deployment default,
23+
because their language is still truly unknown at invitation time. So does an
24+
invitee whose row exists but names no language: an unset column is not a
25+
choice.
26+
27+
⛔ The inviter direction stays rejected on both branches, and is now pinned
28+
against a manager that has the top rung wired rather than against one with no
29+
rung at all. #13881's ruling item 3 fixes the chain as **recipient** locale →
30+
deployment default; what opened here is the invitee's own column, never the
31+
inviter's header.
32+
33+
**Both branches are reachable, measured rather than assumed.**
34+
`sendInvitationEmail`: better-auth's `create-invitation` route rejects only an
35+
address that is already a member of *this* organization
36+
(`USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION`, `routes/crud-invites.mjs` in
37+
the installed 1.7.2), so an existing account invited elsewhere — and the
38+
`resend` branch — reach the callback normally. `sendPhoneInviteSms` reaches a row by
39+
construction: its one in-repo caller, the identity import endpoint's `invite`
40+
policy, **creates** the account and only then sends the SMS.
41+
42+
⚠️ **What the SMS path yields today, stated precisely, because a changeset
43+
becomes release notes.** The rung is wired there and reads the row whenever the
44+
row carries a locale — but `admin-import-users.ts` never writes `locale` (0
45+
occurrences; positive control: `sendInviteSms` appears twice in the same file),
46+
and `sys_user.locale` declares no column default. So on the only in-repo caller
47+
the column is empty at send time and the invitation SMS still resolves to the
48+
**deployment default** — the pre-change behaviour, unchanged for that flow. What
49+
this buys on that surface is the rung itself: an out-of-repo caller, or a future
50+
import that populates `locale`, is read rather than ignored. The behaviour users
51+
see change today is on the invitation **email**.
52+
53+
**Matching is exact, and that is safe rather than merely tolerable here.**
54+
better-auth lowercases the invitee address on the invite route and the stored
55+
`user.email` on sign-up, so both sides of the predicate are already in the same
56+
case; `email` and `phone_number` are both `unique: true` in the `user` table
57+
`sys_user` is backed by. An address that resolves no row lands on the deployment
58+
default, which is the documented floor rather than a failure — and, as
59+
everywhere else on this ladder, a failing recipient read never blocks a send.
60+
61+
**Docs.** `permissions/authentication.mdx` said "The **invitation** SMS reads
62+
the deployment default alone"; that sentence is now false and is corrected. No
63+
shipped page states the invitation *email* locale rule (the auth email ladder is
64+
undocumented as a whole), so nothing else moved.

content/docs/permissions/authentication.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,11 +444,12 @@ The OTP and invitation bodies are localised and tenant-customisable: a
444444
`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite,
445445
channel 'sms', locale)` wins — built-in English and Chinese rows are seeded
446446
once (never overwriting your edits) and can be changed under Setup →
447-
Notification Templates. For the **OTP** the locale is the recipient's own
447+
Notification Templates. Both bodies resolve the same way: the recipient's own
448448
`sys_user.locale` when their account has one, and the deployment default
449449
(`localization.locale` setting) otherwise — the account is matched on its
450450
`phone_number`, so a number no account carries takes the deployment default
451-
too. The **invitation** SMS reads the deployment default alone. Whichever
451+
too. For the **invitation** SMS the account normally does exist, because the
452+
identity import endpoint creates it and only then sends the message. Whichever
452453
locale that names is then resolved with a `zh-CN → zh → en` fallback chain;
453454
holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`,
454455
`{{loginUrl}}` (invitation — `{{baseUrl}}`, the bare origin, is still

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

Lines changed: 239 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,31 @@
1010
* 2026-08-13 ruling had made the deployment default the whole answer and
1111
* rejected `Accept-Language` outright. #14762 then added the rung ABOVE both,
1212
* per the #14788 option-D ruling of 2026-09-03: the recipient's own
13-
* `sys_user.locale` (#13881) when the account holds one. Invitations keep the
14-
* deployment rung — an invitee has no row until acceptance (#14641) — and
15-
* this file pins that abstention too. The ruling text of record lives on
16-
* `AuthManager.setDefaultEmailLocale` / `authEmailLocaleFromRequest` /
17-
* `emailLocaleArg`; the request rung's own cases and the stored rung's are the
18-
* last two describe blocks in this file.
13+
* `sys_user.locale` (#13881) when the account holds one.
14+
*
15+
* #14641 reached the INVITATION send last, and it is the one send with two
16+
* branches rather than one. The card's terminal state read "choose the
17+
* template by the invitee's stored language", which cannot hold for every
18+
* invitee — an invitee generally has no `sys_user` row until acceptance, so
19+
* there is no stored language to read. What IS implementable, and what this
20+
* file pins, is the two-branch shape:
21+
*
22+
* 1. the address ALREADY carries a `sys_user` row — an existing platform
23+
* user invited into a second organization, or a re-invitation → their own
24+
* `locale`;
25+
* 2. a genuinely new invitee with NO row → the deployment default, because
26+
* their language is still truly unknown at invitation time.
27+
*
28+
* ⛔ The INVITER direction stays rejected on both branches: #13881's ruling
29+
* item 3 fixes the chain as RECIPIENT locale → deployment default, and
30+
* stamping the inviter's `Accept-Language` onto the invitee's mail would move
31+
* the defect one seat over. That abstention is pinned here too, now against a
32+
* manager that HAS the top rung wired — the stronger form of the #14319 pin.
33+
*
34+
* The ruling text of record lives on `AuthManager.setDefaultEmailLocale` /
35+
* `authEmailLocaleFromRequest` / `emailLocaleArg`; the request rung's own
36+
* cases, the stored rung's, and the invitation's two branches are the last
37+
* three describe blocks in this file.
1938
*
2039
* Before this, no `sendTemplate` call in `auth-manager.ts` passed a `locale`,
2140
* so `EmailService`'s ladder always resolved `en-US` and the localized rows
@@ -603,10 +622,10 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
603622
expect(sent[0].locale).toBe('zh-CN');
604623
});
605624

606-
it('the INVITATION send is untouched — its rung is #14641\'s', async () => {
607-
// Scope fence, asserted rather than described: an invitee has no sys_user
608-
// row until acceptance, so this send still names the deployment rung even
609-
// when a row for that address would have carried a locale.
625+
it('the INVITATION send reads the SAME rung, on the address — #14641', async () => {
626+
// Was a scope fence ("untouched — its rung is #14641's") until #14641
627+
// landed. The rung is the same one; only the predicate differs, because
628+
// this callback is handed an address rather than a user row.
610629
const dataEngine = { async findOne() { return { locale: 'ja-JP' }; } };
611630
const { capturedConfig, sent } = await boot('es-ES', { dataEngine } as never);
612631
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
@@ -617,6 +636,215 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
617636
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
618637
});
619638
expect(sent[0].template).toBe('auth.invitation');
620-
expect(sent[0].locale).toBe('es-ES');
639+
expect(sent[0].locale).toBe('ja-JP');
640+
expect(sent[0].locale).not.toBe('es-ES');
641+
});
642+
});
643+
644+
// ── #14641 — the invitation send's two branches ────────────────────────────
645+
646+
/**
647+
* A `sys_user` table keyed by ADDRESS, so the only thing separating the two
648+
* branches is whether the invitee's address carries a row. One engine object
649+
* is shared between drives wherever a test needs the branches to be provably
650+
* the same lookup — otherwise "no row" and "no read" would be indistinguishable
651+
* from the outside, since both land on the deployment default.
652+
*/
653+
function emailKeyedEngine(rows: Record<string, unknown>) {
654+
const reads: any[] = [];
655+
return {
656+
reads,
657+
engine: {
658+
async findOne(object: string, query: any) {
659+
reads.push({ object, query });
660+
if (object !== 'sys_user') return null;
661+
const email = (query?.where ?? {}).email as string;
662+
return Object.prototype.hasOwnProperty.call(rows, email)
663+
? { locale: rows[email] }
664+
: null;
665+
},
666+
},
667+
};
668+
}
669+
670+
async function driveInvitation(opts: {
671+
engine: unknown;
672+
deployment?: string;
673+
invitee?: string;
674+
/** The INVITER's browser language — better-auth hands this callback its request. */
675+
header?: string;
676+
}) {
677+
const { capturedConfig, sent } = await boot(opts.deployment, {
678+
dataEngine: opts.engine,
679+
} as never);
680+
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
681+
await org._opts.sendInvitationEmail(
682+
{
683+
email: opts.invitee ?? 'invitee@example.com',
684+
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
685+
organization: { name: 'Northwind' },
686+
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
687+
},
688+
opts.header === undefined
689+
? undefined
690+
: new Request('http://x/invite', { headers: { 'accept-language': opts.header } }),
691+
);
692+
return sent;
693+
}
694+
695+
describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () => {
696+
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
697+
beforeEach(() => {
698+
vi.clearAllMocks();
699+
process.env.OS_MCP_SERVER_ENABLED = 'false';
700+
});
701+
afterEach(() => {
702+
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
703+
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
704+
});
705+
706+
it('BRANCH 1 — an address that already has a row is written in THAT locale', async () => {
707+
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
708+
const sent = await driveInvitation({ engine, deployment: 'en-US' });
709+
expect(sent[0].template).toBe('auth.invitation');
710+
expect(sent[0].locale).toBe('zh-CN');
711+
// The direction that makes the pin real: the deployment's own tag is NOT
712+
// what went out.
713+
expect(sent[0].locale).not.toBe('en-US');
714+
});
715+
716+
it('and the reverse — an en-US invitee on a zh-CN deployment gets English', async () => {
717+
// Swapping the two tags is what rules out a pin that would pass because
718+
// one of them always wins.
719+
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'en-US' });
720+
const sent = await driveInvitation({ engine, deployment: 'zh-CN' });
721+
expect(sent[0].locale).toBe('en-US');
722+
expect(sent[0].locale).not.toBe('zh-CN');
723+
});
724+
725+
it('BRANCH 2 — a genuinely new invitee, no row, still takes the deployment default', async () => {
726+
// ⚠️ Positive control for the zero, and the reason ONE engine drives both
727+
// sends: the same table, the same predicate and the same deployment answer
728+
// zh-CN for an address that carries a row and en-US for one that does not.
729+
// That is what separates "the read ran and found nothing" from "the read
730+
// never ran" / "this engine answers nothing" — both of which would also
731+
// land on the deployment default and look identical from the payload.
732+
const { engine, reads } = emailKeyedEngine({ 'known@example.com': 'zh-CN' });
733+
734+
const known = await driveInvitation({ engine, deployment: 'en-US', invitee: 'known@example.com' });
735+
expect(known[0].locale).toBe('zh-CN');
736+
737+
const newcomer = await driveInvitation({ engine, deployment: 'en-US', invitee: 'newcomer@example.com' });
738+
expect(newcomer[0].locale).toBe('en-US');
739+
expect(newcomer[0].locale).not.toBe('zh-CN');
740+
741+
// ...and the newcomer's read really was attempted, on their address.
742+
const userReads = reads.filter((r) => r.object === 'sys_user');
743+
expect(userReads.map((r) => r.query.where)).toEqual([
744+
{ email: 'known@example.com' },
745+
{ email: 'newcomer@example.com' },
746+
]);
747+
});
748+
749+
it("reads the column off the INVITEE's address — never the inviter's", async () => {
750+
// Establishes WHICH rung produced the value, and on WHOSE identity. The
751+
// inviter has a row too, carrying a different language; it must not be
752+
// reached at all.
753+
const { engine, reads } = emailKeyedEngine({
754+
'invitee@example.com': 'zh-CN',
755+
'dana@example.com': 'ja-JP',
756+
});
757+
const sent = await driveInvitation({ engine, deployment: 'en-US' });
758+
expect(sent[0].locale).toBe('zh-CN');
759+
expect(sent[0].locale).not.toBe('ja-JP');
760+
761+
const userReads = reads.filter((r) => r.object === 'sys_user');
762+
expect(userReads).toHaveLength(1);
763+
expect(userReads[0].query.where).toEqual({ email: 'invitee@example.com' });
764+
expect(userReads[0].query.fields).toEqual(['locale']);
765+
expect(userReads[0].query.context?.isSystem).toBe(true);
766+
});
767+
768+
it("⛔ the INVITER's Accept-Language still loses — with the top rung now wired", async () => {
769+
// The #14319 abstention, re-pinned in its stronger form: this send reads a
770+
// recipient rung now, so "no request argument" is no longer trivially true
771+
// of the whole callback. An English-speaking admin must still not force
772+
// English onto a Chinese workspace's new hire.
773+
const { engine } = emailKeyedEngine({});
774+
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
775+
expect(sent[0].locale).toBe('zh-CN');
776+
expect(sent[0].locale).not.toBe('en-US');
777+
});
778+
779+
it("...and does not win over the invitee's stored column either", async () => {
780+
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'ja-JP' });
781+
const sent = await driveInvitation({ engine, deployment: 'zh-CN', header: 'en-US' });
782+
expect(sent[0].locale).toBe('ja-JP');
783+
expect(sent[0].locale).not.toBe('en-US');
784+
});
785+
786+
it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => {
787+
for (const junk of ['undefined', 'null', '', ' ', 42, {}]) {
788+
const { engine } = emailKeyedEngine({ 'invitee@example.com': junk });
789+
const sent = await driveInvitation({ engine, deployment: 'en-US' });
790+
expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('en-US');
791+
}
792+
});
793+
794+
it('a failing recipient read never blocks the invitation', async () => {
795+
const engine = { async findOne() { throw new Error('sys_user unavailable'); } };
796+
const sent = await driveInvitation({ engine, deployment: 'en-US' });
797+
expect(sent).toHaveLength(1);
798+
expect(sent[0].template).toBe('auth.invitation');
799+
expect(sent[0].locale).toBe('en-US');
800+
});
801+
802+
it('with no data engine at all, the deployment rung answers exactly as before', async () => {
803+
const { capturedConfig, sent } = await boot('en-US');
804+
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
805+
await org._opts.sendInvitationEmail({
806+
email: 'invitee@example.com',
807+
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
808+
organization: { name: 'Northwind' },
809+
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
810+
});
811+
expect(sent[0].locale).toBe('en-US');
812+
});
813+
814+
it('with neither a row nor a deployment default, NO locale is named at all', async () => {
815+
// The ladder's contract is written against an ABSENT key.
816+
const { engine } = emailKeyedEngine({});
817+
const sent = await driveInvitation({ engine });
818+
expect(sent[0].locale).toBeUndefined();
819+
expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false);
820+
});
821+
822+
it('does not disturb the rest of the invitation payload', async () => {
823+
const { engine } = emailKeyedEngine({ 'invitee@example.com': 'zh-CN' });
824+
const sent = await driveInvitation({ engine, deployment: 'en-US' });
825+
expect(sent[0].to).toBe('invitee@example.com');
826+
expect(sent[0].relatedObject).toBe('sys_invitation');
827+
expect(sent[0].relatedId).toBe('inv1');
828+
expect(sent[0].organizationId).toBe('o1');
829+
expect(sent[0].data.organization.name).toBe('Northwind');
830+
expect(sent[0].data.role).toBe('member');
831+
});
832+
833+
it('a placeholder address is still refused BEFORE any recipient read', async () => {
834+
// #2766 V1.5 ordering, re-pinned now that a read sits on this path: the
835+
// refusal must not be preceded by a lookup for an address that is not a
836+
// real recipient.
837+
const { engine, reads } = emailKeyedEngine({});
838+
const { capturedConfig } = await boot('en-US', { dataEngine: engine } as never);
839+
const org = capturedConfig.plugins.find((p: any) => p.id === 'organization');
840+
await expect(
841+
org._opts.sendInvitationEmail({
842+
email: 'u-abcdefghijklmnopqrst@placeholder.invalid',
843+
invitation: { id: 'inv1', organizationId: 'o1', role: 'member' },
844+
organization: { name: 'Northwind' },
845+
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
846+
}),
847+
).rejects.toThrow(/placeholder address/);
848+
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
621849
});
622850
});

0 commit comments

Comments
 (0)