Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable: add columns with a temporary default for existing rows, then drop the default
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" ALTER COLUMN "signUpRiskScoreBot" DROP DEFAULT;
ALTER TABLE "ProjectUser" ALTER COLUMN "signUpRiskScoreFreeTrialAbuse" DROP DEFAULT;
4 changes: 4 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ model ProjectUser {
restrictedByAdminReason String? // Publicly viewable reason (shown to user)
restrictedByAdminPrivateDetails String? // Private details (server access only)

// Sign-up risk scores (0-100, set at sign-up time)
signUpRiskScoreBot Int @db.SmallInt
signUpRiskScoreFreeTrialAbuse Int @db.SmallInt

projectUserOAuthAccounts ProjectUserOAuthAccount[]
teamMembers TeamMember[]
contactChannels ContactChannel[]
Expand Down
6 changes: 5 additions & 1 deletion apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { ensurePermissionDefinition, grantTeamPermission } from '@/lib/permissions';
import { createOrUpdateProjectWithLegacyConfig, getProject } from '@/lib/projects';
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from '@/lib/tenancies';
import { getPrismaClientForTenancy, globalPrismaClient, PrismaClientTransaction } from '@/prisma-client';
import { PrismaClientTransaction, getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client';
import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config';
import { DEFAULT_EMAIL_THEME_ID } from '@stackframe/stack-shared/dist/helpers/emails';
import { AdminUserProjectsCrud, ProjectsCrud } from '@stackframe/stack-shared/dist/interface/crud/projects';
Expand Down Expand Up @@ -318,6 +318,8 @@ export async function seed() {
tenancyId: internalTenancy.id,
mirroredProjectId: 'internal',
mirroredBranchId: DEFAULT_BRANCH_ID,
signUpRiskScoreBot: 0,
signUpRiskScoreFreeTrialAbuse: 0,
}
});

Expand Down Expand Up @@ -447,6 +449,8 @@ export async function seed() {
tenancyId: internalTenancy.id,
mirroredProjectId: 'internal',
mirroredBranchId: DEFAULT_BRANCH_ID,
signUpRiskScoreBot: 0,
signUpRiskScoreFreeTrialAbuse: 0,
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export const POST = createSmartRouteHandler({
email: yupString().optional(),
auth_method: yupString().oneOf(AUTH_METHODS).defined(),
oauth_provider: yupString().optional(),
risk_scores: yupObject({
bot: yupNumber().min(0).max(100).integer().defined(),
free_trial_abuse: yupNumber().min(0).max(100).integer().defined(),
}).defined(),
}).defined(),
}),
response: yupObject({
Expand All @@ -32,6 +36,10 @@ export const POST = createSmartRouteHandler({
email_domain: yupString().defined(),
auth_method: yupString().oneOf(AUTH_METHODS).defined(),
oauth_provider: yupString().defined(),
risk_scores: yupObject({
bot: yupNumber().min(0).max(100).integer().defined(),
free_trial_abuse: yupNumber().min(0).max(100).integer().defined(),
}).defined(),
}).defined(),
evaluations: yupArray(yupObject({
rule_id: yupString().defined(),
Expand All @@ -58,6 +66,10 @@ export const POST = createSmartRouteHandler({
email: req.body.email,
authMethod: req.body.auth_method,
oauthProvider: req.body.oauth_provider,
riskScores: {
bot: req.body.risk_scores.bot,
freeTrialAbuse: req.body.risk_scores.free_trial_abuse,
},
});
const trace = evaluateSignUpRulesWithTrace(req.auth.tenancy, context);

Expand All @@ -70,6 +82,10 @@ export const POST = createSmartRouteHandler({
email_domain: context.emailDomain,
auth_method: context.authMethod,
oauth_provider: context.oauthProvider,
risk_scores: {
bot: context.riskScores.bot,
free_trial_abuse: context.riskScores.freeTrialAbuse,
},
},
evaluations: trace.evaluations.map((evaluation) => ({
rule_id: evaluation.ruleId,
Expand Down
18 changes: 17 additions & 1 deletion apps/backend/src/app/api/latest/users/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
import { KnownErrors } from "@stackframe/stack-shared";
import { currentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
import { UsersCrud, usersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields";
import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64";
import { decodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes";
import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
Expand Down Expand Up @@ -184,6 +184,12 @@ export const userPrismaToCrud = (
restricted_by_admin: prisma.restrictedByAdmin,
restricted_by_admin_reason: prisma.restrictedByAdminReason,
restricted_by_admin_private_details: prisma.restrictedByAdminPrivateDetails,
risk_scores: {
sign_up: {
bot: prisma.signUpRiskScoreBot,
free_trial_abuse: prisma.signUpRiskScoreFreeTrialAbuse,
},
},
};
return result;
};
Expand Down Expand Up @@ -402,6 +408,12 @@ export function getUserQuery(projectId: string, branchId: string, userId: string
restricted_by_admin: row.restrictedByAdmin,
restricted_by_admin_reason: row.restrictedByAdminReason,
restricted_by_admin_private_details: row.restrictedByAdminPrivateDetails,
risk_scores: {
sign_up: {
bot: row.signUpRiskScoreBot,
free_trial_abuse: row.signUpRiskScoreFreeTrialAbuse,
},
},
};
},
};
Expand Down Expand Up @@ -642,6 +654,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
restrictedByAdmin,
restrictedByAdminReason,
restrictedByAdminPrivateDetails,
signUpRiskScoreBot: data.risk_scores?.sign_up.bot ?? 0,
signUpRiskScoreFreeTrialAbuse: data.risk_scores?.sign_up.free_trial_abuse ?? 0,
},
include: userFullInclude,
});
Expand Down Expand Up @@ -1144,6 +1158,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
restrictedByAdmin: data.restricted_by_admin ?? undefined,
restrictedByAdminReason: restrictedByAdminReason,
restrictedByAdminPrivateDetails: restrictedByAdminPrivateDetails,
Comment on lines 1159 to 1160
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe property access on optional nested object

data.risk_scores?.sign_up.bot uses optional chaining on risk_scores but not on sign_up. If risk_scores is present but sign_up is somehow absent at runtime (schema coercion edge case, or a future schema change), this would throw TypeError: Cannot read properties of undefined (reading 'bot').

Use consistent optional chaining throughout:

Suggested change
restrictedByAdminReason: restrictedByAdminReason,
restrictedByAdminPrivateDetails: restrictedByAdminPrivateDetails,
signUpRiskScoreBot: data.risk_scores?.sign_up?.bot,
signUpRiskScoreFreeTrialAbuse: data.risk_scores?.sign_up?.free_trial_abuse,

Fix in Claude Code Fix in Cursor Fix in Codex

signUpRiskScoreBot: data.risk_scores?.sign_up.bot,
signUpRiskScoreFreeTrialAbuse: data.risk_scores?.sign_up.free_trial_abuse,
}),
include: userFullInclude,
});
Expand Down
43 changes: 43 additions & 0 deletions apps/backend/src/lib/cel-evaluator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { evaluate } from "cel-js";
import { normalizeEmail } from "./emails";
import { SignUpRiskScores } from "./risk-scores";

/**
* Custom error class for CEL evaluation failures.
Expand Down Expand Up @@ -32,6 +33,7 @@ export type SignUpRuleContext = {
authMethod: 'password' | 'otp' | 'oauth' | 'passkey',
/** OAuth provider ID if authMethod is "oauth", empty string otherwise */
oauthProvider: string,
riskScores: SignUpRiskScores,
};

/**
Expand Down Expand Up @@ -160,6 +162,7 @@ export function createSignUpRuleContext(params: {
email?: string,
authMethod: 'password' | 'otp' | 'oauth' | 'passkey',
oauthProvider?: string,
riskScores: SignUpRiskScores,
}): SignUpRuleContext {
// Handle missing email (e.g., OAuth providers that don't return email)
// Use empty string so email-based rules don't match
Expand All @@ -178,6 +181,7 @@ export function createSignUpRuleContext(params: {
emailDomain,
authMethod: params.authMethod,
oauthProvider: params.oauthProvider ?? '',
riskScores: params.riskScores,
};
}

Expand All @@ -187,47 +191,79 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => {
expect(createSignUpRuleContext({
email: 'Test.User@Example.COM',
authMethod: 'password',
riskScores: {
bot: 17,
freeTrialAbuse: 23,
},
})).toEqual({
email: 'test.user@example.com',
emailDomain: 'example.com',
authMethod: 'password',
oauthProvider: '',
riskScores: {
bot: 17,
freeTrialAbuse: 23,
},
});

// Should handle missing email (OAuth providers without email)
expect(createSignUpRuleContext({
email: undefined,
authMethod: 'oauth',
oauthProvider: 'discord',
riskScores: {
bot: 1,
freeTrialAbuse: 2,
},
})).toEqual({
email: '',
emailDomain: '',
authMethod: 'oauth',
oauthProvider: 'discord',
riskScores: {
bot: 1,
freeTrialAbuse: 2,
},
});

// Should handle empty string email
expect(createSignUpRuleContext({
email: '',
authMethod: 'oauth',
oauthProvider: 'twitter',
riskScores: {
bot: 10,
freeTrialAbuse: 20,
},
})).toEqual({
email: '',
emailDomain: '',
authMethod: 'oauth',
oauthProvider: 'twitter',
riskScores: {
bot: 10,
freeTrialAbuse: 20,
},
});

// Should handle OAuth with email
expect(createSignUpRuleContext({
email: 'oauth.user@gmail.com',
authMethod: 'oauth',
oauthProvider: 'google',
riskScores: {
bot: 8,
freeTrialAbuse: 9,
},
})).toEqual({
email: 'oauth.user@gmail.com',
emailDomain: 'gmail.com',
authMethod: 'oauth',
oauthProvider: 'google',
riskScores: {
bot: 8,
freeTrialAbuse: 9,
},
});
});

Expand All @@ -237,6 +273,10 @@ import.meta.vitest?.test('evaluateCelExpression with missing email', async ({ ex
email: undefined,
authMethod: 'oauth',
oauthProvider: 'discord',
riskScores: {
bot: 33,
freeTrialAbuse: 44,
},
});

// Email-based conditions should fail when email is empty
Expand All @@ -247,6 +287,9 @@ import.meta.vitest?.test('evaluateCelExpression with missing email', async ({ ex
// But authMethod-based conditions should still work
expect(evaluateCelExpression('authMethod == "oauth"', context)).toBe(true);
expect(evaluateCelExpression('oauthProvider == "discord"', context)).toBe(true);
expect(evaluateCelExpression('riskScores.bot == 33', context)).toBe(true);
expect(evaluateCelExpression('riskScores.freeTrialAbuse == 44', context)).toBe(true);
expect(evaluateCelExpression('riskScores.bot > 10 && riskScores.freeTrialAbuse < 90', context)).toBe(true);

// Empty email should match empty string
expect(evaluateCelExpression('email == ""', context)).toBe(true);
Expand Down
29 changes: 29 additions & 0 deletions apps/backend/src/lib/risk-scores.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Tenancy } from "./tenancies";

export type SignUpRiskScores = {
bot: number,
freeTrialAbuse: number,
};

export type SignUpRiskScoreContext = {
primaryEmail: string | null,
primaryEmailVerified: boolean,
authMethod: 'password' | 'otp' | 'oauth' | 'passkey',
oauthProvider?: string,
ipAddress: string | null,
};

export async function calculateSignUpRiskScores(tenancy: Tenancy, context: SignUpRiskScoreContext): Promise<SignUpRiskScores> {
// TODO
if (context.primaryEmail === "test@example.com") {
return {
bot: 100,
freeTrialAbuse: 100,
};
} else {
return {
bot: 0,
freeTrialAbuse: 0,
};
}
Comment on lines +17 to +28
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calculateSignUpRiskScores is currently a hard-coded stub that flags only test@example.com as high risk. Shipping this as-is will produce incorrect risk scores in production and makes sign-up rules unreliable. Please either implement the real scoring logic, or gate this stub to test-only (e.g., behind an env/config flag) and default to neutral scores otherwise.

Suggested change
// TODO
if (context.primaryEmail === "test@example.com") {
return {
bot: 100,
freeTrialAbuse: 100,
};
} else {
return {
bot: 0,
freeTrialAbuse: 0,
};
}
// TODO: Implement real scoring logic. For now, only apply the stub behavior in tests
if (process.env.NODE_ENV === "test" && context.primaryEmail === "test@example.com") {
return {
bot: 100,
freeTrialAbuse: 100,
};
}
// Default to neutral risk scores in non-test environments
return {
bot: 0,
freeTrialAbuse: 0,
};

Copilot uses AI. Check for mistakes.
}
27 changes: 23 additions & 4 deletions apps/backend/src/lib/users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { KnownErrors } from "@stackframe/stack-shared";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { KeyIntersect } from "@stackframe/stack-shared/dist/utils/types";
import { createSignUpRuleContext } from "./cel-evaluator";
import { calculateSignUpRiskScores } from "./risk-scores";
import { evaluateSignUpRules } from "./sign-up-rules";
import { Tenancy } from "./tenancies";

Expand All @@ -12,6 +13,7 @@ import { Tenancy } from "./tenancies";
export type SignUpRuleOptions = {
authMethod: 'password' | 'otp' | 'oauth' | 'passkey',
oauthProvider?: string,
ipAddress: string | null,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required ipAddress field breaks existing callers

ipAddress: string | null is added as a required (non-optional) property to SignUpRuleOptions. However, two existing callers that were not updated in this PR pass only { authMethod: '...' } without ipAddress, which would be a TypeScript compilation error:

  • apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx calls with { authMethod: 'password' }
  • apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx calls with { authMethod: 'otp' } (and even has a TODO comment acknowledging this)

Either make ipAddress optional (ipAddress?: string | null) or update all callers to explicitly pass null.

Suggested change
ipAddress: string | null,
ipAddress?: string | null,

Fix in Claude Code Fix in Cursor Fix in Codex

Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SignUpRuleOptions now requires ipAddress, but several call sites still pass only { authMethod, oauthProvider } (e.g. password sign-up, OTP verification-code handler). This makes the change a breaking compile-time error. Either make ipAddress optional (defaulting to null) or update all callers to pass a value (or null when unavailable).

Suggested change
ipAddress: string | null,
ipAddress?: string | null,

Copilot uses AI. Check for mistakes.
};
Comment on lines 13 to 17
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all callers of createOrUpgradeAnonymousUserWithRules and check if they pass ipAddress
rg -n -A 10 'createOrUpgradeAnonymousUserWithRules\(' --type ts | head -100

Repository: stack-auth/stack-auth

Length of output: 4091


🏁 Script executed:

# Get the complete call at password sign-up route
sed -n '57,75p' apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx

Repository: stack-auth/stack-auth

Length of output: 593


🏁 Script executed:

# Get the complete call at OTP handler
sed -n '110,130p' apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx

Repository: stack-auth/stack-auth

Length of output: 635


🏁 Script executed:

# Get the complete call at OAuth
sed -n '207,225p' apps/backend/src/lib/oauth.tsx

Repository: stack-auth/stack-auth

Length of output: 623


🏁 Script executed:

# View the SignUpRuleOptions type definition in users.tsx
sed -n '13,17p' apps/backend/src/lib/users.tsx

Repository: stack-auth/stack-auth

Length of output: 211


🏁 Script executed:

# Check if there are any other callers we missed
rg -c 'createOrUpgradeAnonymousUserWithRules\(' --type ts

Repository: stack-auth/stack-auth

Length of output: 277


Critical: ipAddress is now required in SignUpRuleOptions but three callers don't provide it.

The SignUpRuleOptions type defines ipAddress: string | null as a required field, but the following callers pass incomplete objects:

  • apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx:67 — passes only { authMethod: 'password' }
  • apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx:120 — passes only { authMethod: 'otp' }
  • apps/backend/src/lib/oauth.tsx:217 — passes params.signUpRuleOptions which lacks ipAddress

This will cause TypeScript compilation errors at all three sites.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/users.tsx` around lines 13 - 17, SignUpRuleOptions now
requires ipAddress but three callers pass objects without it; either make
ipAddress optional on SignUpRuleOptions or (preferred) update each caller to
supply a value (real IP from the request or null) when constructing the options.
Locate the type SignUpRuleOptions and add ipAddress: string | null where needed,
then update the password sign-up handler (password sign-up), the OTP
verification-code handler (otp sign-in verification), and the OAuth code path
that forwards params.signUpRuleOptions to include an ipAddress field (use
request IP extraction or null if unavailable). Ensure all call sites create a
complete SignUpRuleOptions object to satisfy the type.


/**
Expand Down Expand Up @@ -43,10 +45,21 @@ export async function createOrUpgradeAnonymousUserWithRules(
signUpRuleOptions: SignUpRuleOptions,
): Promise<UsersCrud["Admin"]["Read"]> {
const email = createOrUpdate.primary_email ?? currentUser?.primary_email ?? undefined;
const primaryEmailVerified = createOrUpdate.primary_email_verified ?? currentUser?.primary_email_verified ?? false;

const riskScores = await calculateSignUpRiskScores(tenancy, {
primaryEmail: email ?? null,
primaryEmailVerified,
authMethod: signUpRuleOptions.authMethod,
oauthProvider: signUpRuleOptions.oauthProvider,
ipAddress: signUpRuleOptions.ipAddress,
});

const ruleResult = await evaluateSignUpRules(tenancy, createSignUpRuleContext({
email,
authMethod: signUpRuleOptions.authMethod,
oauthProvider: signUpRuleOptions.oauthProvider,
riskScores,
}));

if (!ruleResult.shouldAllow) {
Expand All @@ -68,17 +81,21 @@ export async function createOrUpgradeAnonymousUserWithRules(
restricted_by_admin: true,
restricted_by_admin_private_details: existingRestrictionPrivateDetails ? `${existingRestrictionPrivateDetails}\n\n${restrictionPrivateDetails}` : restrictionPrivateDetails,
} : {},
risk_scores: {
sign_up: {
bot: riskScores.bot,
free_trial_abuse: riskScores.freeTrialAbuse,
},
},
};

// Proceed with user creation/upgrade
const user = await createOrUpgradeAnonymousUserWithoutRules(
return await createOrUpgradeAnonymousUserWithoutRules(
tenancy,
currentUser,
enrichedCreateOrUpdate as KeyIntersect<UsersCrud["Admin"]["Create"], UsersCrud["Admin"]["Update"]>,
allowedErrorTypes,
);

return user;
}

/**
Expand Down Expand Up @@ -109,9 +126,11 @@ export async function createOrUpgradeAnonymousUserWithoutRules(
});
} else {
// Create new user (normal flow)
// Cast needed: createOrUpdate may contain create-only fields (like risk scores) that
// KeyIntersect<Create, Update> strips from the type since they're absent on Update
return await usersCrudHandlers.adminCreate({
tenancy,
data: createOrUpdate,
data: createOrUpdate as UsersCrud["Admin"]["Create"],
allowedErrorTypes,
});
}
Expand Down
Loading
Loading