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:
Mantra 2026-03-23 12:31:36 -07:00 committed by GitHub
parent 381e057c1f
commit d22593d535
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 321 additions and 365 deletions

4
.gitmodules vendored
View File

@ -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

View File

@ -1,4 +1,5 @@
src/generated
src/private/implementation.generated.ts
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

View File

@ -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',
],

View File

@ -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",

View File

@ -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;

View File

@ -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;

View File

@ -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 };
};

View File

@ -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

View File

@ -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);
};

View File

@ -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());
};

View File

@ -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());
};

View File

@ -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"');
};

View File

@ -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;

View 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();

View File

@ -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();
}

View 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()),
};
},
};

View File

@ -0,0 +1 @@
export { signUpRiskEngine } from "./implementation.generated";

View File

@ -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",

View File

@ -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`.

View File

@ -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