@@ -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 ( / p l a c e h o l d e r a d d r e s s / ) ;
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