rework weights for same name signups (#1298)

- **update submodule**
- **Enhance sign-up risk assessment by adding sameEmailCount and
sameEmailLimit to recent stats request. Update loadRecentSignUpStats
function to include email normalization checks. Adjust tests to reflect
new return structure.**

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Risk scoring now tracks and reports counts of recent signups that
share a normalized email (with configurable limit), exposing this as
part of signup-risk statistics.

* **Performance**
* Added a database index and migration to speed up recent-signup
queries, improving risk assessment responsiveness.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Mantra 2026-04-12 16:30:55 -07:00 committed by GitHub
parent fd158bb54a
commit 328fd0252f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 25 additions and 3 deletions

View File

@ -0,0 +1,5 @@
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_signUpEmailNormalized_recent_idx"
ON "ProjectUser"("tenancyId", "isAnonymous", "signUpEmailNormalized", "signedUpAt");

View File

@ -332,6 +332,7 @@ model ProjectUser {
@@index([tenancyId, createdAt(sort: Desc)], name: "ProjectUser_createdAt_desc")
@@index([tenancyId, isAnonymous, signedUpAt(sort: Asc)], name: "ProjectUser_signedUpAt_asc")
@@index([tenancyId, isAnonymous, signUpIp, signedUpAt], name: "ProjectUser_signUpIp_recent_idx")
@@index([tenancyId, isAnonymous, signUpEmailNormalized, signedUpAt], name: "ProjectUser_signUpEmailNormalized_recent_idx")
@@index([tenancyId, isAnonymous, signUpEmailBase, signedUpAt], name: "ProjectUser_signUpEmailBase_recent_idx")
@@index([tenancyId, sequenceId], name: "ProjectUser_tenancyId_sequenceId_idx")
@@index([shouldUpdateSequenceId, tenancyId], name: "ProjectUser_shouldUpdateSequenceId_idx")

View File

@ -30,14 +30,17 @@ export type SignUpRiskAssessment = {
export type SignUpRiskRecentStatsRequest = {
signedUpAt: Date,
signUpIp: string | null,
signUpEmailNormalized: string | null,
signUpEmailBase: string | null,
recentWindowHours: number,
sameIpLimit: number,
sameEmailLimit: number,
similarEmailLimit: number,
};
export type SignUpRiskRecentStats = {
sameIpCount: number,
sameEmailCount: number,
similarEmailCount: number,
};
@ -64,7 +67,7 @@ async function loadRecentSignUpStats(
const schema = await getPrismaSchemaForTenancy(tenancy);
const windowStart = new Date(request.signedUpAt.getTime() - request.recentWindowHours * 60 * 60 * 1000);
const [sameIpRows, similarEmailRows] = await Promise.all([
const [sameIpRows, sameEmailRows, similarEmailRows] = await Promise.all([
request.signUpIp == null || request.sameIpLimit === 0
? []
: prisma.$replica().$queryRaw<{ matched: number }[]>`
@ -77,6 +80,18 @@ async function loadRecentSignUpStats(
LIMIT ${request.sameIpLimit}
`,
request.signUpEmailNormalized == null || request.sameEmailLimit === 0
? []
: prisma.$replica().$queryRaw<{ matched: number }[]>`
SELECT 1 AS "matched"
FROM ${sqlQuoteIdent(schema)}."ProjectUser"
WHERE "tenancyId" = ${tenancy.id}::UUID
AND "isAnonymous" = false
AND "signedUpAt" >= ${windowStart}
AND "signUpEmailNormalized" = ${request.signUpEmailNormalized}
LIMIT ${request.sameEmailLimit}
`,
request.signUpEmailBase == null || request.similarEmailLimit === 0
? []
: prisma.$replica().$queryRaw<{ matched: number }[]>`
@ -92,6 +107,7 @@ async function loadRecentSignUpStats(
return {
sameIpCount: sameIpRows.length,
sameEmailCount: sameEmailRows.length,
similarEmailCount: similarEmailRows.length,
};
}
@ -144,7 +160,7 @@ import.meta.vitest?.test("loaded private sign-up risk engine can calculate score
turnstileAssessment: { status: "ok" },
}, {
checkPrimaryEmailRisk: async () => ({ emailableScore: null }),
loadRecentSignUpStats: async () => ({ sameIpCount: 0, similarEmailCount: 0 }),
loadRecentSignUpStats: async () => ({ sameIpCount: 0, sameEmailCount: 0, similarEmailCount: 0 }),
});
expect(assessment).toMatchInlineSnapshot(`

@ -1 +1 @@
Subproject commit e4f32c675a640f40ffc19b1013ff46fc633d2438
Subproject commit 576f383b69a9593a9cff8d755c64c810aeeae239