mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-16 21:08:38 +08:00
private files n sm build shit (#1276)
- Introduced a fallback mechanism for the private sign-up risk engine, allowing for zero-score assessments when the primary engine is unavailable. - Updated Next.js configuration to support dynamic resolution of the private risk engine, including aliasing for both Turbopack and Webpack. - Added a new fallback implementation in `private-sign-up-risk-engine-fallback.ts` to ensure consistent behavior during builds. - Adjusted `risk-scores.tsx` to utilize the new compiled engine, improving error handling and logging for risk assessment failures. This update improves the robustness of the sign-up risk scoring system and enhances the development experience by streamlining engine resolution. <!-- 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 * **Improvements** * Sign-up risk engine is initialized and validated at startup for more predictable performance. * If the risk engine is unavailable or invalid, the system immediately returns safe zero-risk scores to avoid runtime failures. * **Tests** * End-to-end tests updated to match the new engine initialization and detection behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
parent
381e057c1f
commit
d22593d535
4
.gitmodules
vendored
4
.gitmodules
vendored
@ -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
|
||||
|
||||
1
apps/backend/.gitignore
vendored
1
apps/backend/.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
src/generated
|
||||
src/private/implementation.generated.ts
|
||||
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
|
||||
@ -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',
|
||||
],
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
};
|
||||
@ -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());
|
||||
};
|
||||
@ -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());
|
||||
};
|
||||
@ -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"');
|
||||
};
|
||||
@ -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;
|
||||
|
||||
25
apps/backend/scripts/generate-private-sign-up-risk-engine.ts
Normal file
25
apps/backend/scripts/generate-private-sign-up-risk-engine.ts
Normal file
@ -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();
|
||||
@ -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<SignUpRiskRecentStats>,
|
||||
};
|
||||
|
||||
export type SignUpRiskEngine = {
|
||||
calculateRiskAssessment: (
|
||||
context: SignUpRiskScoreContext,
|
||||
dependencies: {
|
||||
checkPrimaryEmailRisk: (email: string) => Promise<{ emailableScore: number | null }>,
|
||||
loadRecentSignUpStats: (request: SignUpRiskRecentStatsRequest) => Promise<SignUpRiskRecentStats>,
|
||||
},
|
||||
dependencies: SignUpRiskEngineDependencies,
|
||||
) => Promise<SignUpRiskAssessment>,
|
||||
};
|
||||
|
||||
|
||||
// ── 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<SignUpRiskEngine> | null = null;
|
||||
|
||||
function isSignUpRiskEngine(value: unknown): value is SignUpRiskEngine {
|
||||
return value != null && typeof value === "object" && typeof (value as Record<string, unknown>).calculateRiskAssessment === "function";
|
||||
}
|
||||
|
||||
async function loadEngine(): Promise<SignUpRiskEngine> {
|
||||
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<SignUpRiskEngine> {
|
||||
let mod: Record<string, unknown>;
|
||||
try {
|
||||
mod = await import(/* webpackIgnore: true */ privateEnginePath) as Record<string, unknown>;
|
||||
} 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<SignUpRiskEngine> {
|
||||
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<SignUpRiskEngine["calculateRiskAssessment"]>[1],
|
||||
): Promise<SignUpRiskAssessment> {
|
||||
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<SignUpRiskAssessment> {
|
||||
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();
|
||||
}
|
||||
|
||||
11
apps/backend/src/private/implementation-fallback/index.ts
Normal file
11
apps/backend/src/private/implementation-fallback/index.ts
Normal file
@ -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()),
|
||||
};
|
||||
},
|
||||
};
|
||||
1
apps/backend/src/private/index.ts
Normal file
1
apps/backend/src/private/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { signUpRiskEngine } from "./implementation.generated";
|
||||
@ -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",
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -17,28 +17,6 @@ export default function PageClient() {
|
||||
<Typography type='h3'>Welcome to the Stack demo app!</Typography>
|
||||
<Typography>Try signing in/up with the buttons below!</Typography>
|
||||
<Typography>Also feel free to check out the things on the top right corner.</Typography>
|
||||
<Card className='max-w-xl w-full text-left'>
|
||||
<CardHeader>
|
||||
<Typography type='h4'>Fraud protection</Typography>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2'>
|
||||
<Typography>
|
||||
Turnstile fraud protection is handled transparently by the SDK and hosted pages.
|
||||
</Typography>
|
||||
<Typography className='text-sm'>
|
||||
Coverage: password sign-up, magic links, OAuth, and hosted auth
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<div className='flex gap-2'>
|
||||
<Button onClick={() => router.push(app.urls.signUp)}>Open hosted sign-up</Button>
|
||||
<Button variant='secondary' onClick={() => router.push(app.urls.signIn)}>Open hosted sign-in</Button>
|
||||
<Button variant='secondary' onClick={() => router.push('/turnstile-signup')}>
|
||||
Open auth lab
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<div className='flex gap-2'>
|
||||
<Button onClick={() => router.push(app.urls.signIn)}>Sign In</Button>
|
||||
<Button onClick={() => router.push(app.urls.signUp)}>Sign Up</Button>
|
||||
|
||||
@ -1 +0,0 @@
|
||||
Subproject commit 2f2c03135725c2cf8273304725a93d12f2bc45ec
|
||||
Loading…
Reference in New Issue
Block a user