1- import { isIpAddress } from "@stackframe/stack-shared/dist/utils/ips" ;
2- import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors" ;
3- import { normalizeEmail } from "./emails" ;
4-
5- type EmailProviderRule = {
6- canonicalDomain : string ,
7- stripPlusTag : boolean ,
8- stripDots : boolean ,
9- } ;
10-
11- const emailProviderRules = new Map < string , EmailProviderRule > ( [
12- [ "gmail.com" , { canonicalDomain : "gmail.com" , stripPlusTag : true , stripDots : true } ] ,
13- [ "googlemail.com" , { canonicalDomain : "gmail.com" , stripPlusTag : true , stripDots : true } ] ,
14- [ "outlook.com" , { canonicalDomain : "outlook.com" , stripPlusTag : true , stripDots : false } ] ,
15- [ "hotmail.com" , { canonicalDomain : "hotmail.com" , stripPlusTag : true , stripDots : false } ] ,
16- [ "live.com" , { canonicalDomain : "live.com" , stripPlusTag : true , stripDots : false } ] ,
17- [ "msn.com" , { canonicalDomain : "msn.com" , stripPlusTag : true , stripDots : false } ] ,
18- [ "icloud.com" , { canonicalDomain : "icloud.com" , stripPlusTag : true , stripDots : false } ] ,
19- [ "me.com" , { canonicalDomain : "icloud.com" , stripPlusTag : true , stripDots : false } ] ,
20- [ "mac.com" , { canonicalDomain : "icloud.com" , stripPlusTag : true , stripDots : false } ] ,
21- [ "fastmail.com" , { canonicalDomain : "fastmail.com" , stripPlusTag : true , stripDots : false } ] ,
22- ] ) ;
23-
241export type DerivedSignUpHeuristicFacts = {
252 signUpAt : Date ,
263 signUpIp : string | null ,
@@ -31,181 +8,28 @@ export type DerivedSignUpHeuristicFacts = {
318 emailBase : string | null ,
329} ;
3310
34- export function normalizeSignUpHeuristicIp ( ipAddress : string | null ) : string | null {
35- if ( ipAddress == null ) {
36- return null ;
37- }
38-
39- const normalized = ipAddress . trim ( ) . toLowerCase ( ) ;
40- if ( ! isIpAddress ( normalized ) ) {
41- throw new StackAssertionError ( "Expected sign-up heuristic IP address to already be valid" , { ipAddress } ) ;
42- }
43-
44- return normalized ;
45- }
46-
47- function normalizeEmailParts ( primaryEmail : string | null ) : { localPart : string , domain : string } | null {
48- if ( primaryEmail == null ) {
49- return null ;
50- }
51-
52- const normalized = normalizeEmail ( primaryEmail ) ;
53- const atIndex = normalized . indexOf ( "@" ) ;
54- if ( atIndex < 0 ) {
55- throw new StackAssertionError ( "normalizeEmail returned an invalid address shape" , { primaryEmail, normalized } ) ;
56- }
57- const localPart = normalized . slice ( 0 , atIndex ) ;
58- const domain = normalized . slice ( atIndex + 1 ) ;
59-
60- return { localPart, domain } ;
61- }
62-
63- export function normalizeEmailForSignUpHeuristics ( primaryEmail : string | null ) : string | null {
64- const parts = normalizeEmailParts ( primaryEmail ) ;
65- if ( parts == null ) {
66- return null ;
67- }
68-
69- const providerRule = emailProviderRules . get ( parts . domain ) ;
70- const canonicalDomain = providerRule ?. canonicalDomain ?? parts . domain ;
71-
72- let canonicalLocalPart = parts . localPart ;
73- if ( providerRule ?. stripPlusTag ) {
74- canonicalLocalPart = canonicalLocalPart . split ( "+" ) [ 0 ] ?? canonicalLocalPart ;
75- }
76- if ( providerRule ?. stripDots ) {
77- canonicalLocalPart = canonicalLocalPart . replace ( / \. / g, "" ) ;
78- }
79-
80- return `${ canonicalLocalPart } @${ canonicalDomain } ` ;
81- }
82-
83- export function getBaseEmailForSignUpHeuristics ( primaryEmail : string | null ) : string | null {
84- const parts = normalizeEmailParts ( primaryEmail ) ;
85- if ( parts == null ) {
86- return null ;
87- }
88-
89- const canonicalDomain = emailProviderRules . get ( parts . domain ) ?. canonicalDomain ?? parts . domain ;
90- const dealiased = parts . localPart . replace ( / \+ .* $ / , "" ) ;
91- const base = dealiased
92- . replace ( / [ . _ - ] + / g, "-" ) // normalize separators to a single dash
93- . replace ( / ( - \d + ) + $ / , "" ) // strip trailing -N segments (e.g. alice-12-34 → alice)
94- . replace ( / \d + $ / , "" ) // strip remaining bare trailing digits (e.g. alice123 → alice)
95- . replace ( / ( ^ - | - $ ) / g, "" ) ; // trim leading/trailing dashes
96-
97- return `${ base || dealiased || parts . localPart } @${ canonicalDomain } ` ;
98- }
99-
100- export function deriveSignUpHeuristicFacts ( params : {
101- primaryEmail : string | null ,
102- ipAddress : string | null ,
103- ipTrusted : boolean | null ,
104- recordedAt ?: Date ,
105- } ) : DerivedSignUpHeuristicFacts {
106- const recordedAt = params . recordedAt ?? new Date ( ) ;
107- const normalizedIp = normalizeSignUpHeuristicIp ( params . ipAddress ) ;
108- const emailNormalized = normalizeEmailForSignUpHeuristics ( params . primaryEmail ) ;
109- const emailBase = getBaseEmailForSignUpHeuristics ( params . primaryEmail ) ;
110-
11+ export function createNeutralSignUpHeuristicFacts ( recordedAt : Date = new Date ( ) ) : DerivedSignUpHeuristicFacts {
11112 return {
11213 signUpAt : recordedAt ,
113- signUpIp : normalizedIp ,
114- signUpIpTrusted : normalizedIp == null ? null : params . ipTrusted ,
115- signUpEmailNormalized : emailNormalized ,
116- signUpEmailBase : emailBase ,
117- emailNormalized,
118- emailBase,
14+ signUpIp : null ,
15+ signUpIpTrusted : null ,
16+ signUpEmailNormalized : null ,
17+ signUpEmailBase : null ,
18+ emailNormalized : null ,
19+ emailBase : null ,
11920 } ;
12021}
12122
122- import . meta. vitest ?. test ( "normalizeEmailForSignUpHeuristics(...)" , ( { expect } ) => {
123- const localPartCases = [
124- { localPart : "Example.Test+123" , expectedByDomain : new Map ( [
125- [ "googlemail.com" , "exampletest@gmail.com" ] ,
126- [ "gmail.com" , "exampletest@gmail.com" ] ,
127- [ "outlook.com" , "example.test@outlook.com" ] ,
128- [ "example.com" , "example.test+123@example.com" ] ,
129- ] ) } ,
130- { localPart : "Jane.Doe" , expectedByDomain : new Map ( [
131- [ "googlemail.com" , "janedoe@gmail.com" ] ,
132- [ "gmail.com" , "janedoe@gmail.com" ] ,
133- [ "outlook.com" , "jane.doe@outlook.com" ] ,
134- [ "example.com" , "jane.doe@example.com" ] ,
135- ] ) } ,
136- ] ;
137-
138- for ( const localPartCase of localPartCases ) {
139- for ( const [ domain , expected ] of localPartCase . expectedByDomain ) {
140- expect ( normalizeEmailForSignUpHeuristics ( `${ localPartCase . localPart } @${ domain } ` ) ) . toBe ( expected ) ;
141- }
142- }
143-
144- expect ( normalizeEmailForSignUpHeuristics ( null ) ) . toBeNull ( ) ;
145- } ) ;
146-
147- import . meta. vitest ?. test ( "getBaseEmailForSignUpHeuristics(...)" , ( { expect } ) => {
148- const baseLocalPart = "alice" ;
149- const noisySuffixes = [ "+1" , "+2" , "-3" , "_004" , ".005" , "--006" ] ;
150- for ( const suffix of noisySuffixes ) {
151- expect ( getBaseEmailForSignUpHeuristics ( `${ baseLocalPart } ${ suffix } @example.com` ) ) . toBe ( "alice@example.com" ) ;
152- }
153-
154- // Plus aliases are stripped regardless of content (not just numeric suffixes)
155- const plusAliasCases = [ "alice+sales@example.com" , "alice+team@example.com" , "alice+abc123@example.com" ] ;
156- for ( const plusAliasCase of plusAliasCases ) {
157- expect ( getBaseEmailForSignUpHeuristics ( plusAliasCase ) ) . toBe ( "alice@example.com" ) ;
158- }
159-
160- // Turnstile demo pattern: random hex plus tags all map to the same base
161- const demoEmails = [ "turnstile-demo+a1b2c3d4@example.com" , "turnstile-demo+e5f6a7b8@example.com" ] ;
162- for ( const demoEmail of demoEmails ) {
163- expect ( getBaseEmailForSignUpHeuristics ( demoEmail ) ) . toBe ( "turnstile-demo@example.com" ) ;
164- }
23+ import . meta. vitest ?. test ( "createNeutralSignUpHeuristicFacts(...)" , ( { expect } ) => {
24+ const recordedAt = new Date ( "2026-03-11T00:00:00.000Z" ) ;
16525
166- // Gmail plus aliases also map to the same base
167- expect ( getBaseEmailForSignUpHeuristics ( "alice+1@gmail.com" ) ) . toBe ( "alice@gmail.com" ) ;
168- expect ( getBaseEmailForSignUpHeuristics ( "alice+sales@gmail.com" ) ) . toBe ( "alice@gmail.com" ) ;
169- } ) ;
170-
171- import . meta. vitest ?. test ( "deriveSignUpHeuristicFacts(...)" , ( { expect } ) => {
172- const recordedAt = new Date ( "2026-03-10T00:00:00.000Z" ) ;
173- const cases = [
174- {
175- primaryEmail : "alice+1@example.com" ,
176- ipAddress : " 127.0.0.1 " ,
177- ipTrusted : false ,
178- expected : {
179- signUpIp : "127.0.0.1" ,
180- signUpIpTrusted : false ,
181- signUpEmailNormalized : "alice+1@example.com" ,
182- signUpEmailBase : "alice@example.com" ,
183- } ,
184- } ,
185- {
186- primaryEmail : "Example.Test+123@googlemail.com" ,
187- ipAddress : null ,
188- ipTrusted : true ,
189- expected : {
190- signUpIp : null ,
191- signUpIpTrusted : null ,
192- signUpEmailNormalized : "exampletest@gmail.com" ,
193- signUpEmailBase : "example-test@gmail.com" ,
194- } ,
195- } ,
196- ] ;
197-
198- for ( const testCase of cases ) {
199- expect ( deriveSignUpHeuristicFacts ( {
200- primaryEmail : testCase . primaryEmail ,
201- ipAddress : testCase . ipAddress ,
202- ipTrusted : testCase . ipTrusted ,
203- recordedAt,
204- } ) ) . toMatchObject ( {
205- signUpAt : recordedAt ,
206- ...testCase . expected ,
207- emailNormalized : testCase . expected . signUpEmailNormalized ,
208- emailBase : testCase . expected . signUpEmailBase ,
209- } ) ;
210- }
26+ expect ( createNeutralSignUpHeuristicFacts ( recordedAt ) ) . toEqual ( {
27+ signUpAt : recordedAt ,
28+ signUpIp : null ,
29+ signUpIpTrusted : null ,
30+ signUpEmailNormalized : null ,
31+ signUpEmailBase : null ,
32+ emailNormalized : null ,
33+ emailBase : null ,
34+ } ) ;
21135} ) ;
0 commit comments