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"]
|
[submodule "backend-private-repo"]
|
||||||
path = packages/private
|
path = apps/backend/src/private/implementation
|
||||||
url = https://github.com/stack-auth/private.git
|
url = https://github.com/stack-auth/private.git
|
||||||
branch = main
|
branch = main
|
||||||
|
|||||||
1
apps/backend/.gitignore
vendored
1
apps/backend/.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
src/generated
|
src/generated
|
||||||
|
src/private/implementation.generated.ts
|
||||||
|
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# 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
|
serverMinification: false, // needs to be disabled for oidc-provider to work, which relies on the original constructor names
|
||||||
},
|
},
|
||||||
|
|
||||||
outputFileTracingIncludes: {
|
|
||||||
"/api/**": ["../../packages/private/dist/**"],
|
|
||||||
},
|
|
||||||
|
|
||||||
serverExternalPackages: [
|
serverExternalPackages: [
|
||||||
'oidc-provider',
|
'oidc-provider',
|
||||||
],
|
],
|
||||||
|
|||||||
@ -21,10 +21,12 @@
|
|||||||
"start": "next start --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02",
|
"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": "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",
|
"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": "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-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": "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": "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: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/\\?.*$//')",
|
"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",
|
"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",
|
"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;
|
-- Add the sign-up metadata columns first.
|
||||||
ALTER TABLE "ProjectUser" ADD COLUMN "signUpRiskScoreFreeTrialAbuse" SMALLINT NOT NULL DEFAULT 0;
|
-- `signedUpAt` starts nullable so we can backfill existing rows before enforcing it.
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
ALTER TABLE "ProjectUser"
|
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 "signedUpAt" TIMESTAMP(3),
|
||||||
ADD COLUMN "signUpIp" TEXT,
|
ADD COLUMN "signUpIp" TEXT,
|
||||||
ADD COLUMN "signUpIpTrusted" BOOLEAN,
|
ADD COLUMN "signUpIpTrusted" BOOLEAN,
|
||||||
ADD COLUMN "signUpEmailNormalized" TEXT,
|
ADD COLUMN "signUpEmailNormalized" TEXT,
|
||||||
ADD COLUMN "signUpEmailBase" 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,3 +1,5 @@
|
|||||||
|
-- Backfill `signedUpAt` from `createdAt` in small batches so the migration stays
|
||||||
|
-- safely under the transaction timeout on large tables.
|
||||||
-- SINGLE_STATEMENT_SENTINEL
|
-- SINGLE_STATEMENT_SENTINEL
|
||||||
-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL
|
-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL
|
||||||
WITH to_update AS (
|
WITH to_update AS (
|
||||||
@ -10,7 +12,8 @@ updated AS (
|
|||||||
UPDATE "ProjectUser" pu
|
UPDATE "ProjectUser" pu
|
||||||
SET "signedUpAt" = pu."createdAt"
|
SET "signedUpAt" = pu."createdAt"
|
||||||
FROM to_update tu
|
FROM to_update tu
|
||||||
WHERE pu."tenancyId" = tu."tenancyId" AND pu."projectUserId" = tu."projectUserId"
|
WHERE pu."tenancyId" = tu."tenancyId"
|
||||||
|
AND pu."projectUserId" = tu."projectUserId"
|
||||||
RETURNING 1
|
RETURNING 1
|
||||||
)
|
)
|
||||||
SELECT COUNT(*) > 0 AS should_repeat_migration FROM updated;
|
SELECT COUNT(*) > 0 AS should_repeat_migration FROM updated;
|
||||||
|
|||||||
@ -8,10 +8,54 @@ export const preMigration = async (sql: Sql) => {
|
|||||||
const regularUserId = randomUUID();
|
const regularUserId = randomUUID();
|
||||||
const anonUserId = randomUUID();
|
const anonUserId = randomUUID();
|
||||||
|
|
||||||
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`;
|
await sql`
|
||||||
await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`;
|
INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode")
|
||||||
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${regularUserId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`;
|
VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)
|
||||||
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 "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 };
|
return { regularUserId, anonUserId };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
-- Add the indexes needed for recent sign-up heuristics and sorting.
|
||||||
-- SPLIT_STATEMENT_SENTINEL
|
-- SPLIT_STATEMENT_SENTINEL
|
||||||
-- SINGLE_STATEMENT_SENTINEL
|
-- SINGLE_STATEMENT_SENTINEL
|
||||||
-- RUN_OUTSIDE_TRANSACTION_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"
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_signUpEmailBase_recent_idx"
|
||||||
ON "ProjectUser"("tenancyId", "isAnonymous", "signUpEmailBase", "signedUpAt");
|
ON "ProjectUser"("tenancyId", "isAnonymous", "signUpEmailBase", "signedUpAt");
|
||||||
|
|
||||||
|
-- Validate the risk score bounds once every row has the new columns.
|
||||||
-- SPLIT_STATEMENT_SENTINEL
|
-- SPLIT_STATEMENT_SENTINEL
|
||||||
-- SINGLE_STATEMENT_SENTINEL
|
-- SINGLE_STATEMENT_SENTINEL
|
||||||
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
|
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
|
||||||
@ -26,27 +28,8 @@ ALTER TABLE "ProjectUser" VALIDATE CONSTRAINT "ProjectUser_risk_score_bot_range"
|
|||||||
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
|
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
|
||||||
ALTER TABLE "ProjectUser" VALIDATE CONSTRAINT "ProjectUser_risk_score_free_trial_abuse_range";
|
ALTER TABLE "ProjectUser" VALIDATE CONSTRAINT "ProjectUser_risk_score_free_trial_abuse_range";
|
||||||
|
|
||||||
-- SPLIT_STATEMENT_SENTINEL
|
-- Enforce `signedUpAt` after the backfill is complete. We intentionally require
|
||||||
-- SINGLE_STATEMENT_SENTINEL
|
-- inserts to provide the value explicitly instead of hiding that behavior in a trigger.
|
||||||
-- 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"();
|
|
||||||
|
|
||||||
-- SPLIT_STATEMENT_SENTINEL
|
-- SPLIT_STATEMENT_SENTINEL
|
||||||
-- SINGLE_STATEMENT_SENTINEL
|
-- SINGLE_STATEMENT_SENTINEL
|
||||||
-- RUN_OUTSIDE_TRANSACTION_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,7 +1,7 @@
|
|||||||
|
-- Add the onboarding status with a safe default for existing projects, and
|
||||||
|
-- defer full validation to the next migration.
|
||||||
ALTER TABLE "Project"
|
ALTER TABLE "Project"
|
||||||
ADD COLUMN "onboardingStatus" TEXT NOT NULL DEFAULT 'completed';
|
ADD COLUMN "onboardingStatus" TEXT NOT NULL DEFAULT 'completed',
|
||||||
|
|
||||||
ALTER TABLE "Project"
|
|
||||||
ADD CONSTRAINT "Project_onboardingStatus_valid"
|
ADD CONSTRAINT "Project_onboardingStatus_valid"
|
||||||
CHECK (
|
CHECK (
|
||||||
"onboardingStatus" IN (
|
"onboardingStatus" IN (
|
||||||
|
|||||||
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 { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client";
|
||||||
|
import { signUpRiskEngine } from "@/private";
|
||||||
import type { SignUpRiskScoresCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
|
import type { SignUpRiskScoresCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
|
||||||
import type { SignUpAuthMethod } from "@stackframe/stack-shared/dist/utils/auth-methods";
|
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 { 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 { Tenancy } from "./tenancies";
|
||||||
import type { SignUpTurnstileAssessment } from "./turnstile";
|
import type { SignUpTurnstileAssessment } from "./turnstile";
|
||||||
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────
|
// -- Types -------------------------------------------------------------------
|
||||||
|
|
||||||
export type SignUpRiskScores = SignUpRiskScoresCrud;
|
export type SignUpRiskScores = SignUpRiskScoresCrud;
|
||||||
|
|
||||||
@ -43,99 +41,20 @@ export type SignUpRiskRecentStats = {
|
|||||||
similarEmailCount: number,
|
similarEmailCount: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
type SignUpRiskEngine = {
|
export type SignUpRiskEngineDependencies = {
|
||||||
calculateRiskAssessment: (
|
|
||||||
context: SignUpRiskScoreContext,
|
|
||||||
dependencies: {
|
|
||||||
checkPrimaryEmailRisk: (email: string) => Promise<{ emailableScore: number | null }>,
|
checkPrimaryEmailRisk: (email: string) => Promise<{ emailableScore: number | null }>,
|
||||||
loadRecentSignUpStats: (request: SignUpRiskRecentStatsRequest) => Promise<SignUpRiskRecentStats>,
|
loadRecentSignUpStats: (request: SignUpRiskRecentStatsRequest) => Promise<SignUpRiskRecentStats>,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
export type SignUpRiskEngine = {
|
||||||
|
calculateRiskAssessment: (
|
||||||
|
context: SignUpRiskScoreContext,
|
||||||
|
dependencies: SignUpRiskEngineDependencies,
|
||||||
) => Promise<SignUpRiskAssessment>,
|
) => Promise<SignUpRiskAssessment>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// ── Private engine ─────────────────────────────────────────────────────
|
// -- DB queries --------------------------------------------------------------
|
||||||
|
|
||||||
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 ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function loadRecentSignUpStats(
|
async function loadRecentSignUpStats(
|
||||||
tenancy: Tenancy,
|
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(
|
export async function calculateSignUpRiskAssessment(
|
||||||
tenancy: Tenancy,
|
tenancy: Tenancy,
|
||||||
context: SignUpRiskScoreContext,
|
context: SignUpRiskScoreContext,
|
||||||
): Promise<SignUpRiskAssessment> {
|
): Promise<SignUpRiskAssessment> {
|
||||||
const engine = await getEngine();
|
return await signUpRiskEngine.calculateRiskAssessment(context, createDependencies(tenancy));
|
||||||
return await calculateRiskAssessmentWithFallback(engine, context, createDependencies(tenancy));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function calculateSignUpRiskScores(
|
export async function calculateSignUpRiskScores(
|
||||||
@ -232,18 +122,19 @@ export async function calculateSignUpRiskScores(
|
|||||||
return (await calculateSignUpRiskAssessment(tenancy, context)).scores;
|
return (await calculateSignUpRiskAssessment(tenancy, context)).scores;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Tests -------------------------------------------------------------------
|
||||||
|
|
||||||
// ── Tests ──────────────────────────────────────────────────────────────
|
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)("PRIVATE_ENGINE_PATH resolves in the monorepo", ({ expect }) => {
|
|
||||||
expect(PRIVATE_ENGINE_PATH).toMatch(/packages\/private\/dist\/index\.js$/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
import.meta.vitest?.test.skipIf(!PRIVATE_ENGINE_PATH)("getEngine loads the real engine when available", async ({ expect }) => {
|
import.meta.vitest?.test("loaded private sign-up risk engine can calculate scores", async ({ expect }) => {
|
||||||
cachedEnginePromise = null;
|
const { vi } = import.meta.vitest!;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-03-20T00:00:00.000Z"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const engine = await getEngine();
|
const assessment = await signUpRiskEngine.calculateRiskAssessment({
|
||||||
await engine.calculateRiskAssessment({
|
|
||||||
primaryEmail: null,
|
primaryEmail: null,
|
||||||
primaryEmailVerified: false,
|
primaryEmailVerified: false,
|
||||||
authMethod: "password",
|
authMethod: "password",
|
||||||
@ -255,54 +146,24 @@ import.meta.vitest?.test.skipIf(!PRIVATE_ENGINE_PATH)("getEngine loads the real
|
|||||||
checkPrimaryEmailRisk: async () => ({ emailableScore: null }),
|
checkPrimaryEmailRisk: async () => ({ emailableScore: null }),
|
||||||
loadRecentSignUpStats: async () => ({ sameIpCount: 0, similarEmailCount: 0 }),
|
loadRecentSignUpStats: async () => ({ sameIpCount: 0, similarEmailCount: 0 }),
|
||||||
});
|
});
|
||||||
expect(typeof engine.calculateRiskAssessment).toBe("function");
|
|
||||||
expect(engine).not.toBe(ZERO_SCORE_ENGINE);
|
expect(assessment).toMatchInlineSnapshot(`
|
||||||
} finally {
|
{
|
||||||
cachedEnginePromise = null;
|
"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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
});
|
`);
|
||||||
|
|
||||||
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")));
|
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
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 { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
|
||||||
import { existsSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe } from "vitest";
|
import { describe } from "vitest";
|
||||||
import { it } from "../../../../helpers";
|
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 ZERO_RISK_SCORES = { bot: 0, free_trial_abuse: 0 } as const;
|
||||||
const EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN = "emailable-not-deliverable.example.com";
|
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"))
|
const hasPrivateRiskEngine = readFileSync(
|
||||||
|| existsSync(path.resolve(process.cwd(), "packages/private/dist/sign-up-risk-engine.js"));
|
path.resolve(process.cwd(), "apps/backend/src/private/implementation.generated.ts"),
|
||||||
|
"utf8",
|
||||||
|
).includes("../private/implementation/index");
|
||||||
|
|
||||||
const TRUSTED_IP_FIXTURE = {
|
const TRUSTED_IP_FIXTURE = {
|
||||||
ipAddress: "127.0.0.50",
|
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?
|
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.
|
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 type='h3'>Welcome to the Stack demo app!</Typography>
|
||||||
<Typography>Try signing in/up with the buttons below!</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>
|
<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'>
|
<div className='flex gap-2'>
|
||||||
<Button onClick={() => router.push(app.urls.signIn)}>Sign In</Button>
|
<Button onClick={() => router.push(app.urls.signIn)}>Sign In</Button>
|
||||||
<Button onClick={() => router.push(app.urls.signUp)}>Sign Up</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