diff --git a/apps/backend/prisma/migrations/20260406000000_add_signup_email_normalized_recent_idx/migration.sql b/apps/backend/prisma/migrations/20260406000000_add_signup_email_normalized_recent_idx/migration.sql new file mode 100644 index 000000000..4044d2d09 --- /dev/null +++ b/apps/backend/prisma/migrations/20260406000000_add_signup_email_normalized_recent_idx/migration.sql @@ -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"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 774d4d04e..b7b429512 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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") diff --git a/apps/backend/src/lib/risk-scores.tsx b/apps/backend/src/lib/risk-scores.tsx index 5a8ea3846..fe4650af0 100644 --- a/apps/backend/src/lib/risk-scores.tsx +++ b/apps/backend/src/lib/risk-scores.tsx @@ -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(` diff --git a/apps/backend/src/private/implementation b/apps/backend/src/private/implementation index e4f32c675..576f383b6 160000 --- a/apps/backend/src/private/implementation +++ b/apps/backend/src/private/implementation @@ -1 +1 @@ -Subproject commit e4f32c675a640f40ffc19b1013ff46fc633d2438 +Subproject commit 576f383b69a9593a9cff8d755c64c810aeeae239