diff --git a/.gitmodules b/.gitmodules index 9f4eb2740..5f316ca31 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ -[submodule "packages/private"] - path = packages/private +[submodule "backend-private-repo"] + path = apps/backend/src/private/implementation url = https://github.com/stack-auth/private.git branch = main diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore index fc425cf69..8e09af24b 100644 --- a/apps/backend/.gitignore +++ b/apps/backend/.gitignore @@ -1,4 +1,5 @@ src/generated +src/private/implementation.generated.ts # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. diff --git a/apps/backend/next.config.mjs b/apps/backend/next.config.mjs index c2f48de7e..d8b22a527 100644 --- a/apps/backend/next.config.mjs +++ b/apps/backend/next.config.mjs @@ -57,10 +57,6 @@ const nextConfig = { serverMinification: false, // needs to be disabled for oidc-provider to work, which relies on the original constructor names }, - outputFileTracingIncludes: { - "/api/**": ["../../packages/private/dist/**"], - }, - serverExternalPackages: [ 'oidc-provider', ], diff --git a/apps/backend/package.json b/apps/backend/package.json index 1f47a3bf4..26b5f8a79 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -21,10 +21,12 @@ "start": "next start --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02", "codegen-prisma": "STACK_DATABASE_CONNECTION_STRING=\"${STACK_DATABASE_CONNECTION_STRING:-placeholder-database-connection-string}\" pnpm run prisma generate", "codegen-prisma:watch": "STACK_DATABASE_CONNECTION_STRING=\"${STACK_DATABASE_CONNECTION_STRING:-placeholder-database-connection-string}\" pnpm run prisma generate --watch", + "generate-private-sign-up-risk-engine": "pnpm run with-env tsx scripts/generate-private-sign-up-risk-engine.ts", + "generate-private-sign-up-risk-engine:watch": "chokidar 'src/private/src/sign-up-risk-engine.ts' -c 'pnpm run generate-private-sign-up-risk-engine'", "codegen-route-info": "pnpm run with-env tsx scripts/generate-route-info.ts", "codegen-route-info:watch": "pnpm run with-env tsx watch --clear-screen=false scripts/generate-route-info.ts", - "codegen": "pnpm run with-env pnpm run generate-migration-imports && pnpm run with-env bash -c 'if [ \"$STACK_ACCELERATE_ENABLED\" = \"true\" ]; then pnpm run prisma generate --no-engine; else pnpm run codegen-prisma; fi' && pnpm run codegen-docs && pnpm run codegen-route-info", - "codegen:watch": "concurrently -n \"prisma,docs,route-info,migration-imports\" -k \"pnpm run codegen-prisma:watch\" \"pnpm run codegen-docs:watch\" \"pnpm run codegen-route-info:watch\" \"pnpm run generate-migration-imports:watch\"", + "codegen": "pnpm run with-env pnpm run generate-migration-imports && pnpm run with-env bash -c 'if [ \"$STACK_ACCELERATE_ENABLED\" = \"true\" ]; then pnpm run prisma generate --no-engine; else pnpm run codegen-prisma; fi' && pnpm run generate-private-sign-up-risk-engine && pnpm run codegen-docs && pnpm run codegen-route-info", + "codegen:watch": "pnpm run generate-private-sign-up-risk-engine && concurrently -n \"prisma,private-risk-engine,docs,route-info,migration-imports\" -k \"pnpm run codegen-prisma:watch\" \"pnpm run generate-private-sign-up-risk-engine:watch\" \"pnpm run codegen-docs:watch\" \"pnpm run codegen-route-info:watch\" \"pnpm run generate-migration-imports:watch\"", "psql-inner": "psql $(echo $STACK_DATABASE_CONNECTION_STRING | sed 's/\\?.*$//')", "clickhouse": "pnpm run with-env clickhouse-client --host localhost --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}37 --user stackframe --password PASSWORD-PLACEHOLDER--9gKyMxJeMx", "psql": "pnpm run with-env:dev pnpm run psql-inner", diff --git a/apps/backend/prisma/migrations/20260308000000_add_signup_fraud_protection/migration.sql b/apps/backend/prisma/migrations/20260308000000_add_signup_fraud_protection/migration.sql index d3dc4524e..492c35db6 100644 --- a/apps/backend/prisma/migrations/20260308000000_add_signup_fraud_protection/migration.sql +++ b/apps/backend/prisma/migrations/20260308000000_add_signup_fraud_protection/migration.sql @@ -1,19 +1,18 @@ -ALTER TABLE "ProjectUser" ADD COLUMN "signUpRiskScoreBot" SMALLINT NOT NULL DEFAULT 0; -ALTER TABLE "ProjectUser" ADD COLUMN "signUpRiskScoreFreeTrialAbuse" SMALLINT NOT NULL DEFAULT 0; - -ALTER TABLE "ProjectUser" - ADD CONSTRAINT "ProjectUser_risk_score_bot_range" - CHECK ("signUpRiskScoreBot" >= 0 AND "signUpRiskScoreBot" <= 100) NOT VALID; - -ALTER TABLE "ProjectUser" - ADD CONSTRAINT "ProjectUser_risk_score_free_trial_abuse_range" - CHECK ("signUpRiskScoreFreeTrialAbuse" >= 0 AND "signUpRiskScoreFreeTrialAbuse" <= 100) NOT VALID; - -ALTER TABLE "ProjectUser" ADD COLUMN "signUpCountryCode" TEXT; - +-- Add the sign-up metadata columns first. +-- `signedUpAt` starts nullable so we can backfill existing rows before enforcing it. ALTER TABLE "ProjectUser" + ADD COLUMN "signUpRiskScoreBot" SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN "signUpRiskScoreFreeTrialAbuse" SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN "signUpCountryCode" TEXT, ADD COLUMN "signedUpAt" TIMESTAMP(3), ADD COLUMN "signUpIp" TEXT, ADD COLUMN "signUpIpTrusted" BOOLEAN, ADD COLUMN "signUpEmailNormalized" TEXT, ADD COLUMN "signUpEmailBase" TEXT; + +-- Add the risk score bounds without validating existing rows yet. +ALTER TABLE "ProjectUser" + ADD CONSTRAINT "ProjectUser_risk_score_bot_range" + CHECK ("signUpRiskScoreBot" >= 0 AND "signUpRiskScoreBot" <= 100) NOT VALID, + ADD CONSTRAINT "ProjectUser_risk_score_free_trial_abuse_range" + CHECK ("signUpRiskScoreFreeTrialAbuse" >= 0 AND "signUpRiskScoreFreeTrialAbuse" <= 100) NOT VALID; diff --git a/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/migration.sql b/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/migration.sql index a8f686913..d68e9d7ea 100644 --- a/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/migration.sql +++ b/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/migration.sql @@ -1,16 +1,19 @@ +-- Backfill `signedUpAt` from `createdAt` in small batches so the migration stays +-- safely under the transaction timeout on large tables. -- SINGLE_STATEMENT_SENTINEL -- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL WITH to_update AS ( - SELECT "projectUserId", "tenancyId" - FROM "ProjectUser" - WHERE "signedUpAt" IS NULL - LIMIT 10000 + SELECT "projectUserId", "tenancyId" + FROM "ProjectUser" + WHERE "signedUpAt" IS NULL + LIMIT 10000 ), updated AS ( - UPDATE "ProjectUser" pu - SET "signedUpAt" = pu."createdAt" - FROM to_update tu - WHERE pu."tenancyId" = tu."tenancyId" AND pu."projectUserId" = tu."projectUserId" - RETURNING 1 + UPDATE "ProjectUser" pu + SET "signedUpAt" = pu."createdAt" + FROM to_update tu + WHERE pu."tenancyId" = tu."tenancyId" + AND pu."projectUserId" = tu."projectUserId" + RETURNING 1 ) SELECT COUNT(*) > 0 AS should_repeat_migration FROM updated; diff --git a/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/tests/backfill-and-defaults.ts b/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/tests/backfill-and-defaults.ts index 3375878a8..9189d0025 100644 --- a/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/tests/backfill-and-defaults.ts +++ b/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/tests/backfill-and-defaults.ts @@ -8,10 +8,54 @@ export const preMigration = async (sql: Sql) => { const regularUserId = randomUUID(); const anonUserId = randomUUID(); - await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`; - await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`; - await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${regularUserId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`; - await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt", "isAnonymous") VALUES (${anonUserId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW(), true)`; + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Test', '', false) + `; + await sql` + INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") + VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue") + `; + await sql` + INSERT INTO "ProjectUser" ( + "projectUserId", + "tenancyId", + "mirroredProjectId", + "mirroredBranchId", + "createdAt", + "updatedAt", + "lastActiveAt" + ) VALUES ( + ${regularUserId}::uuid, + ${tenancyId}::uuid, + ${projectId}, + 'main', + NOW(), + NOW(), + NOW() + ) + `; + await sql` + INSERT INTO "ProjectUser" ( + "projectUserId", + "tenancyId", + "mirroredProjectId", + "mirroredBranchId", + "createdAt", + "updatedAt", + "lastActiveAt", + "isAnonymous" + ) VALUES ( + ${anonUserId}::uuid, + ${tenancyId}::uuid, + ${projectId}, + 'main', + NOW(), + NOW(), + NOW(), + true + ) + `; return { regularUserId, anonUserId }; }; diff --git a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/migration.sql b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/migration.sql index e5103d26a..fe2b0f525 100644 --- a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/migration.sql +++ b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/migration.sql @@ -1,3 +1,4 @@ +-- Add the indexes needed for recent sign-up heuristics and sorting. -- SPLIT_STATEMENT_SENTINEL -- SINGLE_STATEMENT_SENTINEL -- RUN_OUTSIDE_TRANSACTION_SENTINEL @@ -16,6 +17,7 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_signUpIp_recent_idx" CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_signUpEmailBase_recent_idx" ON "ProjectUser"("tenancyId", "isAnonymous", "signUpEmailBase", "signedUpAt"); +-- Validate the risk score bounds once every row has the new columns. -- SPLIT_STATEMENT_SENTINEL -- SINGLE_STATEMENT_SENTINEL -- RUN_OUTSIDE_TRANSACTION_SENTINEL @@ -26,27 +28,8 @@ ALTER TABLE "ProjectUser" VALIDATE CONSTRAINT "ProjectUser_risk_score_bot_range" -- RUN_OUTSIDE_TRANSACTION_SENTINEL ALTER TABLE "ProjectUser" VALIDATE CONSTRAINT "ProjectUser_risk_score_free_trial_abuse_range"; --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE OR REPLACE FUNCTION "set_project_user_signed_up_at_from_created_at"() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW."signedUpAt" IS NULL THEN - NEW."signedUpAt" := NEW."createdAt"; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE TRIGGER "ProjectUser_set_signedUpAt_from_createdAt" -BEFORE INSERT ON "ProjectUser" -FOR EACH ROW -EXECUTE FUNCTION "set_project_user_signed_up_at_from_created_at"(); - +-- Enforce `signedUpAt` after the backfill is complete. We intentionally require +-- inserts to provide the value explicitly instead of hiding that behavior in a trigger. -- SPLIT_STATEMENT_SENTINEL -- SINGLE_STATEMENT_SENTINEL -- RUN_OUTSIDE_TRANSACTION_SENTINEL diff --git a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/constraints-validated.ts b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/constraints-validated.ts deleted file mode 100644 index 872233dc3..000000000 --- a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/constraints-validated.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Sql } from 'postgres'; -import { expect } from 'vitest'; - -export const postMigration = async (sql: Sql) => { - const triggers = await sql` - SELECT tgname - FROM pg_trigger - WHERE tgrelid = '"ProjectUser"'::regclass - AND tgname = 'ProjectUser_set_signedUpAt_from_createdAt' - AND NOT tgisinternal - `; - expect(triggers).toHaveLength(1); - - const constraints = await sql` - SELECT conname, convalidated - FROM pg_constraint - WHERE conrelid = '"ProjectUser"'::regclass - AND conname IN ( - 'ProjectUser_risk_score_bot_range', - 'ProjectUser_risk_score_free_trial_abuse_range', - 'ProjectUser_signedUpAt_not_null' - ) - ORDER BY conname - `; - - expect(constraints).toHaveLength(3); - for (const c of constraints) { - expect(c.convalidated, `${c.conname} should be validated`).toBe(true); - } - - const colInfo = await sql` - SELECT is_nullable, column_default - FROM information_schema.columns - WHERE table_name = 'ProjectUser' AND column_name = 'signedUpAt' - `; - expect(colInfo).toHaveLength(1); - expect(colInfo[0].is_nullable).toBe('NO'); - expect(colInfo[0].column_default).toBe(null); -}; diff --git a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/default-on-insert.ts b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/default-on-insert.ts deleted file mode 100644 index 799fef264..000000000 --- a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/default-on-insert.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { randomUUID } from 'crypto'; -import type { Sql } from 'postgres'; -import { expect } from 'vitest'; - -export const postMigration = async (sql: Sql) => { - const projectId = `test-${randomUUID()}`; - const tenancyId = randomUUID(); - const userId = randomUUID(); - - await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`; - await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`; - await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${userId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`; - - const rows = await sql` - SELECT "signedUpAt", "createdAt" - FROM "ProjectUser" - WHERE "projectUserId" = ${userId}::uuid - `; - - expect(rows).toHaveLength(1); - expect(rows[0].signedUpAt).not.toBeNull(); - expect(rows[0].signedUpAt.toISOString()).toBe(rows[0].createdAt.toISOString()); -}; diff --git a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/finalized-signup-fraud-protection.ts b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/finalized-signup-fraud-protection.ts new file mode 100644 index 000000000..90e431541 --- /dev/null +++ b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/finalized-signup-fraud-protection.ts @@ -0,0 +1,139 @@ +import { randomUUID } from 'crypto'; +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +export const postMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + const tenancyId = randomUUID(); + const userId = randomUUID(); + const explicitSignedUpAt = '2026-03-08 12:34:56.789'; + + const triggers = await sql` + SELECT tgname + FROM pg_trigger + WHERE tgrelid = '"ProjectUser"'::regclass + AND tgname = 'ProjectUser_set_signedUpAt_from_createdAt' + AND NOT tgisinternal + `; + expect(triggers).toHaveLength(0); + + const functions = await sql` + SELECT proname + FROM pg_proc + WHERE proname = 'set_project_user_signed_up_at_from_created_at' + `; + expect(functions).toHaveLength(0); + + const constraints = await sql` + SELECT conname, convalidated + FROM pg_constraint + WHERE conrelid = '"ProjectUser"'::regclass + AND conname IN ( + 'ProjectUser_risk_score_bot_range', + 'ProjectUser_risk_score_free_trial_abuse_range', + 'ProjectUser_signedUpAt_not_null' + ) + ORDER BY conname + `; + expect(constraints).toHaveLength(3); + for (const constraint of constraints) { + expect(constraint.convalidated, `${constraint.conname} should be validated`).toBe(true); + } + + const indexes = await sql` + SELECT indexname, indexdef + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'ProjectUser' + AND indexname IN ( + 'ProjectUser_signedUpAt_asc', + 'ProjectUser_signUpIp_recent_idx', + 'ProjectUser_signUpEmailBase_recent_idx' + ) + ORDER BY indexname + `; + expect(indexes.map((row) => row.indexname)).toEqual([ + 'ProjectUser_signUpEmailBase_recent_idx', + 'ProjectUser_signUpIp_recent_idx', + 'ProjectUser_signedUpAt_asc', + ]); + + const indexDefByName = Object.fromEntries(indexes.map((row) => [row.indexname, row.indexdef])); + expect(indexDefByName['ProjectUser_signedUpAt_asc']).toContain('"tenancyId", "isAnonymous", "signedUpAt"'); + expect(indexDefByName['ProjectUser_signUpIp_recent_idx']).toContain('"tenancyId", "isAnonymous", "signUpIp", "signedUpAt"'); + expect(indexDefByName['ProjectUser_signUpEmailBase_recent_idx']).toContain('"tenancyId", "isAnonymous", "signUpEmailBase", "signedUpAt"'); + + const colInfo = await sql` + SELECT is_nullable, column_default + FROM information_schema.columns + WHERE table_name = 'ProjectUser' + AND column_name = 'signedUpAt' + `; + expect(colInfo).toHaveLength(1); + expect(colInfo[0].is_nullable).toBe('NO'); + expect(colInfo[0].column_default).toBe(null); + + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Test', '', false) + `; + await sql` + INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") + VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue") + `; + + await expect(sql` + INSERT INTO "ProjectUser" ( + "projectUserId", + "tenancyId", + "mirroredProjectId", + "mirroredBranchId", + "createdAt", + "updatedAt", + "lastActiveAt" + ) VALUES ( + ${userId}::uuid, + ${tenancyId}::uuid, + ${projectId}, + 'main', + NOW(), + NOW(), + NOW() + ) + `).rejects.toThrow(/signedUpAt/); + + await sql` + INSERT INTO "ProjectUser" ( + "projectUserId", + "tenancyId", + "mirroredProjectId", + "mirroredBranchId", + "createdAt", + "updatedAt", + "lastActiveAt", + "signedUpAt" + ) VALUES ( + ${userId}::uuid, + ${tenancyId}::uuid, + ${projectId}, + 'main', + NOW(), + NOW(), + NOW(), + ${explicitSignedUpAt}::timestamp + ) + `; + + const insertedRows = await sql` + SELECT + "signedUpAt", + "createdAt", + "signedUpAt" = ${explicitSignedUpAt}::timestamp AS "matchesExplicitSignedUpAt" + FROM "ProjectUser" + WHERE "projectUserId" = ${userId}::uuid + `; + expect(insertedRows).toHaveLength(1); + expect(insertedRows[0].signedUpAt).not.toBeNull(); + expect(insertedRows[0].matchesExplicitSignedUpAt).toBe(true); + expect(insertedRows[0].signedUpAt.toISOString()).not.toBe(insertedRows[0].createdAt.toISOString()); +}; diff --git a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/indexes-exist.ts b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/indexes-exist.ts deleted file mode 100644 index 79ff1c003..000000000 --- a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/indexes-exist.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Sql } from 'postgres'; -import { expect } from 'vitest'; - -export const postMigration = async (sql: Sql) => { - const indexes = await sql` - SELECT indexname, indexdef - FROM pg_indexes - WHERE schemaname = current_schema() - AND tablename = 'ProjectUser' - AND indexname IN ( - 'ProjectUser_signedUpAt_asc', - 'ProjectUser_signUpIp_recent_idx', - 'ProjectUser_signUpEmailBase_recent_idx' - ) - ORDER BY indexname - `; - - expect(indexes.map((row) => row.indexname)).toEqual([ - 'ProjectUser_signUpEmailBase_recent_idx', - 'ProjectUser_signUpIp_recent_idx', - 'ProjectUser_signedUpAt_asc', - ]); - - const indexDefByName = Object.fromEntries(indexes.map((row) => [row.indexname, row.indexdef])); - - expect(indexDefByName['ProjectUser_signedUpAt_asc']).toContain('"tenancyId", "isAnonymous", "signedUpAt"'); - expect(indexDefByName['ProjectUser_signUpIp_recent_idx']).toContain('"tenancyId", "isAnonymous", "signUpIp", "signedUpAt"'); - expect(indexDefByName['ProjectUser_signUpEmailBase_recent_idx']).toContain('"tenancyId", "isAnonymous", "signUpEmailBase", "signedUpAt"'); -}; diff --git a/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql b/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql index e238be468..018ac9a19 100644 --- a/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql +++ b/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql @@ -1,16 +1,16 @@ +-- Add the onboarding status with a safe default for existing projects, and +-- defer full validation to the next migration. ALTER TABLE "Project" -ADD COLUMN "onboardingStatus" TEXT NOT NULL DEFAULT 'completed'; - -ALTER TABLE "Project" -ADD CONSTRAINT "Project_onboardingStatus_valid" -CHECK ( - "onboardingStatus" IN ( - 'config_choice', - 'apps_selection', - 'auth_setup', - 'domain_setup', - 'email_theme_setup', - 'payments_setup', - 'completed' - ) -) NOT VALID; + ADD COLUMN "onboardingStatus" TEXT NOT NULL DEFAULT 'completed', + ADD CONSTRAINT "Project_onboardingStatus_valid" + CHECK ( + "onboardingStatus" IN ( + 'config_choice', + 'apps_selection', + 'auth_setup', + 'domain_setup', + 'email_theme_setup', + 'payments_setup', + 'completed' + ) + ) NOT VALID; diff --git a/apps/backend/scripts/generate-private-sign-up-risk-engine.ts b/apps/backend/scripts/generate-private-sign-up-risk-engine.ts new file mode 100644 index 000000000..78a00b5a9 --- /dev/null +++ b/apps/backend/scripts/generate-private-sign-up-risk-engine.ts @@ -0,0 +1,25 @@ +import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; +import fs from "node:fs"; +import path from "node:path"; + +const generatedFilePath = path.join("src", "private", "implementation.generated.ts"); +const privateEnginePath = path.join("src", "private", "implementation", "index.ts"); + +function main() { + const existingContents = fs.existsSync(generatedFilePath) + ? fs.readFileSync(generatedFilePath, "utf8") + : null; + const generatedFileContents = deindent` + // NOTE: This file is auto-generated by the generate-private-sign-up-risk-engine script. Do not edit it manually! Edit the implementation source files instead. + export * from "../private/${fs.existsSync(privateEnginePath) ? "implementation" : "implementation-fallback"}/index"; + ` + "\n"; + + if (existingContents !== generatedFileContents) { + fs.writeFileSync(generatedFilePath, generatedFileContents); + console.log("Successfully updated private sign-up risk engine entrypoint"); + } else { + console.log("No changes needed to private sign-up risk engine entrypoint"); + } +} + +main(); diff --git a/apps/backend/src/lib/risk-scores.tsx b/apps/backend/src/lib/risk-scores.tsx index a04174ae5..5a8ea3846 100644 --- a/apps/backend/src/lib/risk-scores.tsx +++ b/apps/backend/src/lib/risk-scores.tsx @@ -1,16 +1,14 @@ import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client"; +import { signUpRiskEngine } from "@/private"; import type { SignUpRiskScoresCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import type { SignUpAuthMethod } from "@stackframe/stack-shared/dist/utils/auth-methods"; -import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import fs from "node:fs"; -import path from "node:path"; import { checkEmailWithEmailable } from "./emailable"; -import { createNeutralSignUpHeuristicFacts, type DerivedSignUpHeuristicFacts } from "./sign-up-heuristics"; +import { type DerivedSignUpHeuristicFacts } from "./sign-up-heuristics"; import type { Tenancy } from "./tenancies"; import type { SignUpTurnstileAssessment } from "./turnstile"; -// ── Types ────────────────────────────────────────────────────────────── +// -- Types ------------------------------------------------------------------- export type SignUpRiskScores = SignUpRiskScoresCrud; @@ -43,99 +41,20 @@ export type SignUpRiskRecentStats = { similarEmailCount: number, }; -type SignUpRiskEngine = { +export type SignUpRiskEngineDependencies = { + checkPrimaryEmailRisk: (email: string) => Promise<{ emailableScore: number | null }>, + loadRecentSignUpStats: (request: SignUpRiskRecentStatsRequest) => Promise, +}; + +export type SignUpRiskEngine = { calculateRiskAssessment: ( context: SignUpRiskScoreContext, - dependencies: { - checkPrimaryEmailRisk: (email: string) => Promise<{ emailableScore: number | null }>, - loadRecentSignUpStats: (request: SignUpRiskRecentStatsRequest) => Promise, - }, + dependencies: SignUpRiskEngineDependencies, ) => Promise, }; -// ── Private engine ───────────────────────────────────────────────────── - -const ZERO_SCORES: SignUpRiskScores = { bot: 0, free_trial_abuse: 0 }; - -export const PRIVATE_ENGINE_PATH: string | null = (() => { - const cwd = process.cwd(); - for (const relative of ["packages/private/dist/index.js", "../../packages/private/dist/index.js"]) { - const resolved = path.resolve(cwd, relative); - if (fs.existsSync(resolved)) return resolved; - } - return null; -})(); - -function createZeroRiskAssessment(now: Date): SignUpRiskAssessment { - return { scores: ZERO_SCORES, heuristicFacts: createNeutralSignUpHeuristicFacts(now) }; -} - -const ZERO_SCORE_ENGINE: SignUpRiskEngine = { - async calculateRiskAssessment() { - return createZeroRiskAssessment(new Date()); - }, -}; - -let cachedEnginePromise: Promise | null = null; - -function isSignUpRiskEngine(value: unknown): value is SignUpRiskEngine { - return value != null && typeof value === "object" && typeof (value as Record).calculateRiskAssessment === "function"; -} - -async function loadEngine(): Promise { - if (PRIVATE_ENGINE_PATH == null) { - console.debug("[risk-scores] Private sign-up risk engine not found; using zero scores"); - return ZERO_SCORE_ENGINE; - } - - return await loadEngineFromPath(PRIVATE_ENGINE_PATH); -} - -async function loadEngineFromPath(privateEnginePath: string): Promise { - let mod: Record; - try { - mod = await import(/* webpackIgnore: true */ privateEnginePath) as Record; - } catch (error) { - captureError("sign-up-risk-engine-load", new StackAssertionError( - "Failed to import private sign-up risk engine; using zero scores fallback", - { - cause: error, - path: privateEnginePath, - }, - )); - return ZERO_SCORE_ENGINE; - } - const engine = mod.signUpRiskEngine; - if (!isSignUpRiskEngine(engine)) { - captureError("sign-up-risk-engine-invalid", new StackAssertionError( - "Private engine does not export a valid signUpRiskEngine; using zero scores fallback", - { path: privateEnginePath }, - )); - return ZERO_SCORE_ENGINE; - } - console.info("[risk-scores] Loaded private sign-up risk engine from", privateEnginePath); - return engine; -} - -async function getEngine(): Promise { - if (cachedEnginePromise != null) return await cachedEnginePromise; - - const enginePromise = loadEngine(); - cachedEnginePromise = enginePromise; - - try { - return await enginePromise; - } catch (error) { - if (cachedEnginePromise === enginePromise) { - cachedEnginePromise = null; - } - throw error; - } -} - - -// ── DB queries ───────────────────────────────────────────────────────── +// -- DB queries -------------------------------------------------------------- async function loadRecentSignUpStats( tenancy: Tenancy, @@ -186,43 +105,14 @@ function createDependencies(tenancy: Tenancy) { }; } -async function calculateRiskAssessmentWithFallback( - engine: SignUpRiskEngine, - context: SignUpRiskScoreContext, - dependencies: Parameters[1], -): Promise { - try { - return await engine.calculateRiskAssessment(context, dependencies); - } catch (error) { - captureError("sign-up-risk-assessment-failed", new StackAssertionError( - "Sign-up risk assessment failed; using zero scores fallback", - { - cause: error, - privateEnginePath: PRIVATE_ENGINE_PATH, - context: { - authMethod: context.authMethod, - oauthProvider: context.oauthProvider, - hasPrimaryEmail: context.primaryEmail != null, - primaryEmailVerified: context.primaryEmailVerified, - hasIpAddress: context.ipAddress != null, - ipTrusted: context.ipTrusted, - turnstileAssessment: context.turnstileAssessment, - }, - }, - )); - return createZeroRiskAssessment(new Date()); - } -} - -// ── Public API ───────────────────────────────────────────────────────── +// -- Public API -------------------------------------------------------------- export async function calculateSignUpRiskAssessment( tenancy: Tenancy, context: SignUpRiskScoreContext, ): Promise { - const engine = await getEngine(); - return await calculateRiskAssessmentWithFallback(engine, context, createDependencies(tenancy)); + return await signUpRiskEngine.calculateRiskAssessment(context, createDependencies(tenancy)); } export async function calculateSignUpRiskScores( @@ -232,18 +122,19 @@ export async function calculateSignUpRiskScores( return (await calculateSignUpRiskAssessment(tenancy, context)).scores; } +// -- Tests ------------------------------------------------------------------- -// ── Tests ────────────────────────────────────────────────────────────── - -import.meta.vitest?.test.skipIf(!PRIVATE_ENGINE_PATH)("PRIVATE_ENGINE_PATH resolves in the monorepo", ({ expect }) => { - expect(PRIVATE_ENGINE_PATH).toMatch(/packages\/private\/dist\/index\.js$/); +import.meta.vitest?.test("private sign-up risk engine resolves at module init", ({ expect }) => { + expect(typeof signUpRiskEngine.calculateRiskAssessment).toBe("function"); }); -import.meta.vitest?.test.skipIf(!PRIVATE_ENGINE_PATH)("getEngine loads the real engine when available", async ({ expect }) => { - cachedEnginePromise = null; +import.meta.vitest?.test("loaded private sign-up risk engine can calculate scores", async ({ expect }) => { + const { vi } = import.meta.vitest!; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-20T00:00:00.000Z")); + try { - const engine = await getEngine(); - await engine.calculateRiskAssessment({ + const assessment = await signUpRiskEngine.calculateRiskAssessment({ primaryEmail: null, primaryEmailVerified: false, authMethod: "password", @@ -255,54 +146,24 @@ import.meta.vitest?.test.skipIf(!PRIVATE_ENGINE_PATH)("getEngine loads the real checkPrimaryEmailRisk: async () => ({ emailableScore: null }), loadRecentSignUpStats: async () => ({ sameIpCount: 0, similarEmailCount: 0 }), }); - expect(typeof engine.calculateRiskAssessment).toBe("function"); - expect(engine).not.toBe(ZERO_SCORE_ENGINE); - } finally { - cachedEnginePromise = null; - } -}); -import.meta.vitest?.test("loadEngine returns zero-score engine when private engine import fails", async ({ expect }) => { - const missingPrivateEnginePath = path.join(process.cwd(), "__missing-risk-engine__.js"); - const engine = await loadEngineFromPath(missingPrivateEnginePath); - expect(engine).toBe(ZERO_SCORE_ENGINE); -}); - -import.meta.vitest?.test("loadEngineFromPath returns zero-score engine when private engine export is invalid", async ({ expect }) => { - const invalidPrivateEnginePath = path.join(process.cwd(), "__invalid-risk-engine__.mjs"); - const invalidPrivateEngineSource = "export const signUpRiskEngine = {};\n"; - fs.writeFileSync(invalidPrivateEnginePath, invalidPrivateEngineSource); - - try { - const engine = await loadEngineFromPath(invalidPrivateEnginePath); - expect(engine).toBe(ZERO_SCORE_ENGINE); - } finally { - fs.unlinkSync(invalidPrivateEnginePath); - } -}); - -import.meta.vitest?.test("calculateRiskAssessmentWithFallback returns zero scores on engine error", async ({ expect }) => { - const { vi } = import.meta.vitest!; - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-20T00:00:00.000Z")); - - try { - const assessment = await calculateRiskAssessmentWithFallback({ - async calculateRiskAssessment() { throw new Error("boom"); }, - }, { - primaryEmail: "user@example.com", - primaryEmailVerified: false, - authMethod: "password", - oauthProvider: null, - ipAddress: "127.0.0.1", - ipTrusted: true, - turnstileAssessment: { status: "ok" }, - }, { - checkPrimaryEmailRisk: async () => ({ emailableScore: null }), - loadRecentSignUpStats: async () => ({ sameIpCount: 0, similarEmailCount: 0 }), - }); - - expect(assessment).toEqual(createZeroRiskAssessment(new Date("2026-03-20T00:00:00.000Z"))); + expect(assessment).toMatchInlineSnapshot(` + { + "heuristicFacts": { + "emailBase": null, + "emailNormalized": null, + "signUpEmailBase": null, + "signUpEmailNormalized": null, + "signUpIp": null, + "signUpIpTrusted": null, + "signedUpAt": 2026-03-20T00:00:00.000Z, + }, + "scores": { + "bot": 0, + "free_trial_abuse": 0, + }, + } + `); } finally { vi.useRealTimers(); } diff --git a/apps/backend/src/private/implementation-fallback/index.ts b/apps/backend/src/private/implementation-fallback/index.ts new file mode 100644 index 000000000..71e416ea5 --- /dev/null +++ b/apps/backend/src/private/implementation-fallback/index.ts @@ -0,0 +1,11 @@ +import { SignUpRiskEngine } from "@/lib/risk-scores"; +import { createNeutralSignUpHeuristicFacts } from "@/lib/sign-up-heuristics"; + +export const signUpRiskEngine: SignUpRiskEngine = { + async calculateRiskAssessment() { + return { + scores: { bot: 0, free_trial_abuse: 0 }, + heuristicFacts: createNeutralSignUpHeuristicFacts(new Date()), + }; + }, +}; diff --git a/apps/backend/src/private/index.ts b/apps/backend/src/private/index.ts new file mode 100644 index 000000000..371f3710c --- /dev/null +++ b/apps/backend/src/private/index.ts @@ -0,0 +1 @@ +export { signUpRiskEngine } from "./implementation.generated"; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts index 581fca597..d3d339d7e 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts @@ -1,5 +1,5 @@ import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; -import { existsSync } from "node:fs"; +import { readFileSync } from "node:fs"; import path from "node:path"; import { describe } from "vitest"; import { it } from "../../../../helpers"; @@ -7,8 +7,10 @@ import { Auth, InternalApiKey, Project, backendContext, mockTurnstileTokens, nic const ZERO_RISK_SCORES = { bot: 0, free_trial_abuse: 0 } as const; const EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN = "emailable-not-deliverable.example.com"; -const hasPrivateRiskEngine = existsSync(path.resolve(process.cwd(), "packages/private/src/sign-up-risk-engine.ts")) - || existsSync(path.resolve(process.cwd(), "packages/private/dist/sign-up-risk-engine.js")); +const hasPrivateRiskEngine = readFileSync( + path.resolve(process.cwd(), "apps/backend/src/private/implementation.generated.ts"), + "utf8", +).includes("../private/implementation/index"); const TRUSTED_IP_FIXTURE = { ipAddress: "127.0.0.50", diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 4d5b54ba9..f682b9ef6 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -99,3 +99,6 @@ A: Update affected inline snapshots in `apps/e2e/tests/backend/endpoints/api/v1/ Q: How should `createOrUpdateProjectWithLegacyConfig` handle `onboardingStatus` for forward-compat checks? A: Only write `onboardingStatus` when the `Project.onboardingStatus` column exists (for example by checking `information_schema.columns` in-transaction) so current code can still run against older schemas where that column is absent. + +Q: Where is the private sign-up risk engine generated entrypoint in backend now? +A: The generator script writes `apps/backend/src/private/implementation.generated.ts` (not `src/generated/private-sign-up-risk-engine.ts`), and backend runtime imports should target `@/private/implementation.generated`. diff --git a/examples/demo/src/app/page-client.tsx b/examples/demo/src/app/page-client.tsx index 265ea46d3..11092fbb9 100644 --- a/examples/demo/src/app/page-client.tsx +++ b/examples/demo/src/app/page-client.tsx @@ -17,28 +17,6 @@ export default function PageClient() { Welcome to the Stack demo app! Try signing in/up with the buttons below! Also feel free to check out the things on the top right corner. - - - Fraud protection - - - - Turnstile fraud protection is handled transparently by the SDK and hosted pages. - - - Coverage: password sign-up, magic links, OAuth, and hosted auth - - - -
- - - -
-
-
diff --git a/packages/private b/packages/private deleted file mode 160000 index 2f2c03135..000000000 --- a/packages/private +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2f2c03135725c2cf8273304725a93d12f2bc45ec