Skip to content

Commit 291a731

Browse files
committed
fix(plugin-auth): the magic-link mail reads the recipient's own sys_user.locale (#15106)
`sendMagicLink` was the last of the five auth mail sends still on the two-rung #14319 ladder — the request's `Accept-Language`, then the deployment default. #14762 put the recipient's stored `sys_user.locale` above both at the three sends that hold a user row and #14641 reached the invitation; the magic link was fenced out because it is handed `{ email, url, token }` and no row, so the column has to be read on the address rather than on an id. It now reads the column behind the existing placeholder-address refusal, in the shape #14641 gave the invitation send: one projected `findOne` on `sys_user` under a system context, best-effort, never a reason a send fails. This completes the #14788 option-D ladder across the whole auth mail surface. The request rung is kept rather than replaced — a magic link is requested BY its recipient, so its `Accept-Language` is the recipient's own and remains the legitimate second rung. The address is lowercased for the lookup: better-auth applies no case transform to the magic-link request body, while `findUserByEmail` — what `/magic-link/verify` resolves the same link with — matches on `email.toLowerCase()`, so the column is read for the row the link will sign into. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent 2e35765 commit 291a731

3 files changed

Lines changed: 370 additions & 6 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): the magic-link mail reads the recipient's own `sys_user.locale` (#15106)
6+
7+
`sendMagicLink` was the last of the five auth mail sends still on the two-rung
8+
#14319 ladder — the request's `Accept-Language`, then the deployment default.
9+
#14762 put the recipient's stored `sys_user.locale` above both at the three
10+
sends that hold a user row, and #14641 reached the invitation; the magic link
11+
was fenced out because it is handed `{ email, url, token }` and no row, so the
12+
column has to be read on the address rather than on an id. The visible cost was
13+
one deployment answering the same person in two languages: a Chinese
14+
password-reset mail and an English magic link, decided by whichever browser
15+
happened to send the request.
16+
17+
It now reads the column behind the existing placeholder-address refusal, in the
18+
same shape #14641 gave the invitation send — one projected `findOne` on
19+
`sys_user` under a system context, best-effort, and never a reason a send fails.
20+
This completes the #14788 option-D ladder (`sys_user.locale` when set → the
21+
request's `Accept-Language` → the deployment default) across the whole auth mail
22+
surface: all five `sendTemplate` sites now answer per recipient.
23+
24+
The request rung is kept rather than replaced. A magic link is requested BY its
25+
recipient, so its `Accept-Language` is the recipient's own and remains a
26+
legitimate second rung for an account that has stated no language; ruling D
27+
inserts the column above the header, it does not remove the header.
28+
29+
Two branches, because a magic link is also a sign-up: an address that carries a
30+
row is written in that account's language, and an address with no row keeps
31+
exactly the previous behaviour. The address is lowercased for the lookup —
32+
better-auth applies no case transform to the magic-link request body, while
33+
`findUserByEmail`, which `/magic-link/verify` resolves the very same link with,
34+
matches on `email.toLowerCase()`, so the column is read for the row the link
35+
will sign into. An address that resolves nothing lands on the rungs below, which
36+
is the documented floor.

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

Lines changed: 294 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ const USER = { id: 'u1', email: 'ada@example.com', name: 'Ada' };
126126
* callback — that placement is itself the #8019 "does not gate" guarantee — so
127127
* it is driven through that hook with a synthetic success context.
128128
*/
129-
async function driveAllFive(locale: string | undefined) {
130-
const { capturedConfig, sent } = await boot(locale);
129+
async function driveAllFive(locale: string | undefined, extra: Record<string, unknown> = {}) {
130+
const { capturedConfig, sent } = await boot(locale, extra);
131131

132132
await capturedConfig.emailAndPassword.sendResetPassword({
133133
user: USER,
@@ -848,3 +848,295 @@ describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", ()
848848
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
849849
});
850850
});
851+
852+
// ── #15106 — the magic-link send reaches the same top rung ─────────────────
853+
854+
/**
855+
* The last of the five sends to read the recipient's own `sys_user.locale`.
856+
*
857+
* ⚠️ POPULATION of the class claim these blocks close, stated rather than
858+
* implied: the auth mail surface is the five `sendTemplate` call sites in
859+
* `auth-manager.ts` — `IEmailService` declares exactly two delivering members
860+
* (`send`, `sendTemplate`), plugin-auth's only `.send(` calls are the SMS
861+
* service's, and no other file under `plugin-auth/src` calls either. Those
862+
* five are the same five `driveAllFive` above drives and `TEMPLATES` names.
863+
* Before this change four of them read the column and `sendMagicLink` did not;
864+
* `the whole surface reads it` is pinned below by driving all five against one
865+
* recipient-keyed engine, not by asserting it about this send alone.
866+
*
867+
* Two branches, because a magic link is BOTH a sign-in for an existing account
868+
* and a sign-up for a new address (measured in the installed better-auth
869+
* 1.7.2: `/sign-in/magic-link` sends without looking the address up, and
870+
* `/magic-link/verify` creates the user unless `disableSignUp`). Branch 2 is
871+
* therefore a real state here, not a theoretical one.
872+
*
873+
* ⛔ The request rung is kept, not replaced. Ruling D (#14788, 2026-09-03,
874+
* maintainer verbatim 「同意」) reads `sys_user.locale` when set → the
875+
* request's `Accept-Language` → the deployment default; a magic link is
876+
* requested by its own recipient, so its `ctx` header is the recipient's and
877+
* stays the legitimate SECOND rung. The pins below assert both directions:
878+
* the column outranks the header, and the header still answers with no column.
879+
*/
880+
function recipientKeyedEngine(rows: { byId?: Record<string, unknown>; byEmail?: Record<string, unknown> }) {
881+
const reads: any[] = [];
882+
const has = (o: Record<string, unknown> | undefined, k: unknown) =>
883+
typeof k === 'string' && !!o && Object.prototype.hasOwnProperty.call(o, k);
884+
return {
885+
reads,
886+
engine: {
887+
async findOne(object: string, query: any) {
888+
reads.push({ object, query });
889+
if (object !== 'sys_user') return null;
890+
const where = (query?.where ?? {}) as Record<string, unknown>;
891+
if (has(rows.byId, where.id)) return { locale: rows.byId![where.id as string] };
892+
if (has(rows.byEmail, where.email)) return { locale: rows.byEmail![where.email as string] };
893+
return null;
894+
},
895+
},
896+
};
897+
}
898+
899+
async function driveMagicLink(opts: {
900+
engine?: unknown;
901+
deployment?: string;
902+
recipient?: string;
903+
/** The RECIPIENT's own browser language — magic link is requested by them. */
904+
header?: string;
905+
}) {
906+
const { capturedConfig, sent } = await boot(
907+
opts.deployment,
908+
opts.engine === undefined ? {} : ({ dataEngine: opts.engine } as never),
909+
);
910+
const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
911+
await magic._opts.sendMagicLink(
912+
{ email: opts.recipient ?? 'ada@example.com', url: 'http://x/magic', token: 't' },
913+
opts.header === undefined
914+
? undefined
915+
: { request: new Request('http://x/magic', { headers: { 'accept-language': opts.header } }) },
916+
);
917+
return sent;
918+
}
919+
920+
describe("#15106 — the magic link reads the recipient's own sys_user.locale", () => {
921+
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
922+
beforeEach(() => {
923+
vi.clearAllMocks();
924+
process.env.OS_MCP_SERVER_ENABLED = 'false';
925+
});
926+
afterEach(() => {
927+
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
928+
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
929+
});
930+
931+
it('the stored column outranks BOTH the request header and the deployment default', async () => {
932+
// Three rungs, three distinct tags — each assertion names exactly one.
933+
const { engine } = recipientKeyedEngine({ byEmail: { 'ada@example.com': 'ja-JP' } });
934+
const sent = await driveMagicLink({ engine, header: 'zh-CN', deployment: 'es-ES' });
935+
expect(sent).toHaveLength(1);
936+
expect(sent[0].template).toBe('auth.magic_link');
937+
expect(sent[0].locale).toBe('ja-JP');
938+
expect(sent[0].locale).not.toBe('zh-CN');
939+
expect(sent[0].locale).not.toBe('es-ES');
940+
});
941+
942+
it('and the reverse — swapping the tags swaps nothing but the answer', async () => {
943+
// Rules out a pin that passes because one particular tag always wins.
944+
const { engine } = recipientKeyedEngine({ byEmail: { 'ada@example.com': 'zh-CN' } });
945+
const sent = await driveMagicLink({ engine, header: 'ja-JP', deployment: 'es-ES' });
946+
expect(sent[0].locale).toBe('zh-CN');
947+
expect(sent[0].locale).not.toBe('ja-JP');
948+
});
949+
950+
it('BRANCH 2 — an address with no row still takes the request rung, #14319 intact', async () => {
951+
// ⚠️ Positive control for the zero, driven through ONE engine: the same
952+
// table and the same predicate answer ja-JP for an address that carries a
953+
// row and fall through for one that does not. Without it, "the read ran
954+
// and found nothing" is indistinguishable from "the read never ran".
955+
const { engine, reads } = recipientKeyedEngine({ byEmail: { 'known@example.com': 'ja-JP' } });
956+
957+
const known = await driveMagicLink({ engine, recipient: 'known@example.com', header: 'zh-CN', deployment: 'es-ES' });
958+
expect(known[0].locale).toBe('ja-JP');
959+
960+
const newcomer = await driveMagicLink({ engine, recipient: 'newcomer@example.com', header: 'zh-CN', deployment: 'es-ES' });
961+
expect(newcomer[0].locale).toBe('zh-CN');
962+
expect(newcomer[0].locale).not.toBe('ja-JP');
963+
964+
// ...and the newcomer's read really was attempted, on their address.
965+
const userReads = reads.filter((r) => r.object === 'sys_user');
966+
expect(userReads.map((r) => r.query.where)).toEqual([
967+
{ email: 'known@example.com' },
968+
{ email: 'newcomer@example.com' },
969+
]);
970+
});
971+
972+
it('with neither a row nor a request, the deployment rung answers — #8195 intact', async () => {
973+
const { engine } = recipientKeyedEngine({});
974+
const sent = await driveMagicLink({ engine, deployment: 'es-ES' });
975+
expect(sent[0].locale).toBe('es-ES');
976+
});
977+
978+
it('with nothing at all, NO locale is named and the documented en-US floor applies', async () => {
979+
// The ladder's contract is written against an ABSENT key, not an explicit
980+
// `undefined` — the same shape the other four sends are pinned on.
981+
const { engine } = recipientKeyedEngine({});
982+
const sent = await driveMagicLink({ engine });
983+
expect(sent[0].locale).toBeUndefined();
984+
expect(Object.prototype.hasOwnProperty.call(sent[0], 'locale')).toBe(false);
985+
});
986+
987+
it("reads the column off the recipient's ADDRESS, projected, under a system context", async () => {
988+
// Establishes WHICH rung produced the value and on WHAT predicate: this
989+
// callback is handed no user row, so an id-keyed read would be a bug.
990+
const { engine, reads } = recipientKeyedEngine({ byEmail: { 'ada@example.com': 'ja-JP' } });
991+
const sent = await driveMagicLink({ engine, header: 'zh-CN' });
992+
expect(sent[0].locale).toBe('ja-JP');
993+
const userReads = reads.filter((r) => r.object === 'sys_user');
994+
expect(userReads).toHaveLength(1);
995+
expect(userReads[0].query.where).toEqual({ email: 'ada@example.com' });
996+
expect(userReads[0].query.fields).toEqual(['locale']);
997+
expect(userReads[0].query.context?.isSystem).toBe(true);
998+
});
999+
1000+
it('matches the address better-auth itself will resolve the link with — lowercased', async () => {
1001+
// ⚠️ Measured against the installed better-auth 1.7.2, not assumed:
1002+
// `signInMagicLinkBodySchema` applies no case transform, so a typed
1003+
// `Ada@Example.com` arrives here verbatim, while
1004+
// `internalAdapter.findUserByEmail` — what `/magic-link/verify` resolves
1005+
// this very link with — matches on `email.toLowerCase()`. The column must
1006+
// be read for the row the link will sign into.
1007+
const { engine, reads } = recipientKeyedEngine({ byEmail: { 'ada@example.com': 'ja-JP' } });
1008+
const sent = await driveMagicLink({ engine, recipient: 'Ada@Example.com', deployment: 'es-ES' });
1009+
const userReads = reads.filter((r) => r.object === 'sys_user');
1010+
expect(userReads, 'no sys_user read happened at all').toHaveLength(1);
1011+
expect(userReads[0].query.where).toEqual({ email: 'ada@example.com' });
1012+
expect(sent[0].locale).toBe('ja-JP');
1013+
// ...and the address the mail is actually sent to is untouched.
1014+
expect(sent[0].to).toBe('Ada@Example.com');
1015+
});
1016+
1017+
it('refuses the stringified-nothing literals a lossy producer leaves at rest', async () => {
1018+
for (const junk of ['undefined', 'null', '', ' ', 42, {}]) {
1019+
const { engine } = recipientKeyedEngine({ byEmail: { 'ada@example.com': junk } });
1020+
const sent = await driveMagicLink({ engine, deployment: 'es-ES' });
1021+
expect(sent[0].locale, `stored ${JSON.stringify(junk)} named a locale`).toBe('es-ES');
1022+
}
1023+
});
1024+
1025+
it('a failing recipient read never blocks the magic link', async () => {
1026+
// A magic link IS the credential — a locale lookup must never be why one
1027+
// fails to arrive.
1028+
const sent = await driveMagicLink({
1029+
engine: { async findOne() { throw new Error('sys_user unavailable'); } },
1030+
header: 'zh-CN',
1031+
deployment: 'es-ES',
1032+
});
1033+
expect(sent).toHaveLength(1);
1034+
expect(sent[0].template).toBe('auth.magic_link');
1035+
expect(sent[0].locale).toBe('zh-CN');
1036+
});
1037+
1038+
it('with no data engine at all, the two-rung #14319 behaviour is exactly intact', async () => {
1039+
const sent = await driveMagicLink({ header: 'zh-CN', deployment: 'es-ES' });
1040+
expect(sent[0].locale).toBe('zh-CN');
1041+
});
1042+
1043+
it('does not disturb the rest of the magic-link payload', async () => {
1044+
const { engine } = recipientKeyedEngine({ byEmail: { 'ada@example.com': 'ja-JP' } });
1045+
const sent = await driveMagicLink({ engine, deployment: 'es-ES' });
1046+
expect(sent[0].to).toBe('ada@example.com');
1047+
expect(sent[0].data.magicLinkUrl).toBe('http://x/magic');
1048+
expect(sent[0].data.token).toBe('t');
1049+
expect(sent[0].data.expiresInMinutes).toBe(10);
1050+
});
1051+
1052+
it('a placeholder address is still refused BEFORE any recipient read', async () => {
1053+
// #2766 V1.5 ordering, re-pinned now that a read sits on this path too.
1054+
const { engine, reads } = recipientKeyedEngine({});
1055+
const { capturedConfig } = await boot('es-ES', { dataEngine: engine } as never);
1056+
const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
1057+
await expect(
1058+
magic._opts.sendMagicLink({
1059+
email: 'u-abcdefghijklmnopqrst@placeholder.invalid',
1060+
url: 'http://x/magic',
1061+
token: 't',
1062+
}),
1063+
).rejects.toThrow(/placeholder address/);
1064+
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
1065+
});
1066+
1067+
it('no recipient read happens at all when no transport is wired', async () => {
1068+
// The no-email-service branch returns before the lookup: an unwired
1069+
// deployment must not pay for a query whose answer it cannot use.
1070+
const { engine, reads } = recipientKeyedEngine({ byEmail: { 'ada@example.com': 'ja-JP' } });
1071+
const { betterAuth } = await import('better-auth');
1072+
let capturedConfig: any;
1073+
(betterAuth as any).mockImplementation((config: any) => {
1074+
capturedConfig = config;
1075+
return { handler: vi.fn(), api: {} };
1076+
});
1077+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1078+
const manager = new AuthManager({
1079+
secret: 'test-secret-at-least-32-chars-long',
1080+
baseUrl: 'http://localhost:3000',
1081+
emailAndPassword: { enabled: true },
1082+
plugins: { magicLink: true },
1083+
dataEngine: engine,
1084+
} as never);
1085+
await manager.getAuthInstance();
1086+
const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
1087+
await magic._opts.sendMagicLink({ email: 'ada@example.com', url: 'http://x/magic', token: 't' });
1088+
warnSpy.mockRestore();
1089+
expect(reads.filter((r) => r.object === 'sys_user')).toHaveLength(0);
1090+
});
1091+
});
1092+
1093+
describe('#15106 — the CLASS claim: all five auth sends read the recipient column', () => {
1094+
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
1095+
beforeEach(() => {
1096+
vi.clearAllMocks();
1097+
process.env.OS_MCP_SERVER_ENABLED = 'false';
1098+
});
1099+
afterEach(() => {
1100+
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
1101+
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
1102+
});
1103+
1104+
it('every one of the five sends is written in the stored language of its own recipient', async () => {
1105+
// ⚠️ POPULATION: the five `sendTemplate` sites in `auth-manager.ts` — the
1106+
// same five `driveAllFive` drives and `TEMPLATES` names, and the whole of
1107+
// plugin-auth's mail surface (see this section's header). This is the pin
1108+
// that closes the class; the per-send pins above only cover one member.
1109+
//
1110+
// The tags are chosen so no send can borrow another's answer: the three
1111+
// id-keyed sends read `ja-JP`, the invitation reads `zh-CN` off the
1112+
// invitee's address, and the magic link reads `en-GB` off ITS recipient's
1113+
// address — a different tag from the SAME person's id-keyed row, which is
1114+
// what proves the magic link reads by address rather than by id.
1115+
const { engine } = recipientKeyedEngine({
1116+
byId: { u1: 'ja-JP' },
1117+
byEmail: { 'invitee@example.com': 'zh-CN', 'ada@example.com': 'en-GB' },
1118+
});
1119+
const sent = await driveAllFive('es-ES', { dataEngine: engine } as never);
1120+
1121+
expect(sent.map((s: any) => s.template)).toEqual([...TEMPLATES]);
1122+
expect(sent.map((s: any) => s.locale)).toEqual([
1123+
'ja-JP', // auth.password_reset — id u1
1124+
'ja-JP', // auth.verify_email — id u1
1125+
'zh-CN', // auth.invitation — invitee@example.com
1126+
'en-GB', // auth.magic_link — ada@example.com (by ADDRESS)
1127+
'ja-JP', // auth.email_change_notice — id u1
1128+
]);
1129+
// The direction that makes it real: not one of the five fell through to
1130+
// the deployment default.
1131+
expect(sent.some((s: any) => s.locale === 'es-ES')).toBe(false);
1132+
});
1133+
1134+
it('...and with an empty table every one of the five falls back, none stuck', async () => {
1135+
// The negative half of the same population: the class claim is about the
1136+
// rung being WIRED at all five sites, so the fallback must also be five.
1137+
const { engine } = recipientKeyedEngine({});
1138+
const sent = await driveAllFive('es-ES', { dataEngine: engine } as never);
1139+
expect(sent.map((s: any) => s.template)).toEqual([...TEMPLATES]);
1140+
expect(sent.map((s: any) => s.locale)).toEqual(['es-ES', 'es-ES', 'es-ES', 'es-ES', 'es-ES']);
1141+
});
1142+
});

0 commit comments

Comments
 (0)