From 4c8ed5e2f21e74f1103580e3bd37dc22a336181a Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 8 Mar 2026 16:16:03 -0700 Subject: [PATCH 01/77] Risk scores --- .../migration.sql | 5 + apps/backend/prisma/schema.prisma | 4 + apps/backend/prisma/seed.ts | 6 +- .../internal/sign-up-rules-test/route.tsx | 16 + .../backend/src/app/api/latest/users/crud.tsx | 18 +- apps/backend/src/lib/cel-evaluator.ts | 43 ++ apps/backend/src/lib/risk-scores.tsx | 29 + apps/backend/src/lib/users.tsx | 27 +- .../[projectId]/sign-up-rules/page-client.tsx | 47 +- .../users/[userId]/page-client.tsx | 6 + .../rule-builder/condition-builder.tsx | 32 +- .../src/lib/cel-visual-parser.test.ts | 28 + apps/dashboard/src/lib/cel-visual-parser.ts | 121 +++- .../v1/internal/sign-up-rules-test.test.ts | 45 ++ .../endpoints/api/v1/risk-scores.test.ts | 569 ++++++++++++++++++ .../stack-shared/src/interface/crud/users.ts | 18 + .../apps/implementations/server-app-impl.ts | 6 + .../template/src/lib/stack-app/users/index.ts | 7 + 18 files changed, 1009 insertions(+), 18 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260308000000_add_signup_risk_scores/migration.sql create mode 100644 apps/backend/src/lib/risk-scores.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts diff --git a/apps/backend/prisma/migrations/20260308000000_add_signup_risk_scores/migration.sql b/apps/backend/prisma/migrations/20260308000000_add_signup_risk_scores/migration.sql new file mode 100644 index 0000000000..73a3a13d25 --- /dev/null +++ b/apps/backend/prisma/migrations/20260308000000_add_signup_risk_scores/migration.sql @@ -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; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0ce2ed3914..cc54d874ce 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -221,6 +221,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[] diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 2663aa188c..6ed47a942c 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -7,7 +7,7 @@ import { overrideBranchConfigOverride, overrideEnvironmentConfigOverride, setBra 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'; @@ -315,6 +315,8 @@ export async function seed() { tenancyId: internalTenancy.id, mirroredProjectId: 'internal', mirroredBranchId: DEFAULT_BRANCH_ID, + signUpRiskScoreBot: 0, + signUpRiskScoreFreeTrialAbuse: 0, } }); @@ -448,6 +450,8 @@ export async function seed() { tenancyId: internalTenancy.id, mirroredProjectId: 'internal', mirroredBranchId: DEFAULT_BRANCH_ID, + signUpRiskScoreBot: 0, + signUpRiskScoreFreeTrialAbuse: 0, } }); diff --git a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx index cc55fd772c..090480fc21 100644 --- a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx +++ b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx @@ -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({ @@ -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(), @@ -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); @@ -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, diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 3a16996d3d..9428795e8c 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -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"; @@ -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; }; @@ -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, + }, + }, }; }, }; @@ -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, }); @@ -1144,6 +1158,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC restrictedByAdmin: data.restricted_by_admin ?? undefined, restrictedByAdminReason: restrictedByAdminReason, restrictedByAdminPrivateDetails: restrictedByAdminPrivateDetails, + signUpRiskScoreBot: data.risk_scores?.sign_up.bot, + signUpRiskScoreFreeTrialAbuse: data.risk_scores?.sign_up.free_trial_abuse, }), include: userFullInclude, }); diff --git a/apps/backend/src/lib/cel-evaluator.ts b/apps/backend/src/lib/cel-evaluator.ts index 33900b1fb1..970bd32b21 100644 --- a/apps/backend/src/lib/cel-evaluator.ts +++ b/apps/backend/src/lib/cel-evaluator.ts @@ -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. @@ -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, }; /** @@ -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 @@ -178,6 +181,7 @@ export function createSignUpRuleContext(params: { emailDomain, authMethod: params.authMethod, oauthProvider: params.oauthProvider ?? '', + riskScores: params.riskScores, }; } @@ -187,11 +191,19 @@ 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) @@ -199,11 +211,19 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { 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 @@ -211,11 +231,19 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { 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 @@ -223,11 +251,19 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { 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, + }, }); }); @@ -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 @@ -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); diff --git a/apps/backend/src/lib/risk-scores.tsx b/apps/backend/src/lib/risk-scores.tsx new file mode 100644 index 0000000000..d890578b52 --- /dev/null +++ b/apps/backend/src/lib/risk-scores.tsx @@ -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 { + // TODO + if (context.primaryEmail === "test@example.com") { + return { + bot: 100, + freeTrialAbuse: 100, + }; + } else { + return { + bot: 0, + freeTrialAbuse: 0, + }; + } +} diff --git a/apps/backend/src/lib/users.tsx b/apps/backend/src/lib/users.tsx index 4de1115a7a..a9df28dfb2 100644 --- a/apps/backend/src/lib/users.tsx +++ b/apps/backend/src/lib/users.tsx @@ -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"; @@ -12,6 +13,7 @@ import { Tenancy } from "./tenancies"; export type SignUpRuleOptions = { authMethod: 'password' | 'otp' | 'oauth' | 'passkey', oauthProvider?: string, + ipAddress: string | null, }; /** @@ -43,10 +45,21 @@ export async function createOrUpgradeAnonymousUserWithRules( signUpRuleOptions: SignUpRuleOptions, ): Promise { 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) { @@ -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, allowedErrorTypes, ); - - return user; } /** @@ -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 strips from the type since they're absent on Update return await usersCrudHandlers.adminCreate({ tenancy, - data: createOrUpdate, + data: createOrUpdate as UsersCrud["Admin"]["Create"], allowedErrorTypes, }); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx index ae32c451b2..60de270c82 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -87,6 +87,10 @@ type SignUpRulesTestResult = { email_domain: string, auth_method: 'password' | 'otp' | 'oauth' | 'passkey', oauth_provider: string, + risk_scores: { + bot: number, + free_trial_abuse: number, + }, }, evaluations: SignUpRulesTestEvaluation[], outcome: { @@ -521,6 +525,8 @@ function TestRulesCard({ const [email, setEmail] = useState(''); const [authMethod, setAuthMethod] = useState('password'); const [oauthProvider, setOauthProvider] = useState(''); + const [riskScoreBot, setRiskScoreBot] = useState(0); + const [riskScoreFreeTrialAbuse, setRiskScoreFreeTrialAbuse] = useState(0); const [result, setResult] = useState(null); const [runTest, isRunning] = useAsyncCallback(async () => { @@ -532,6 +538,10 @@ function TestRulesCard({ email: email || undefined, auth_method: authMethod, oauth_provider: authMethod === 'oauth' ? (oauthProvider || undefined) : undefined, + risk_scores: { + bot: riskScoreBot, + free_trial_abuse: riskScoreFreeTrialAbuse, + }, }), headers: { 'Content-Type': 'application/json', @@ -546,7 +556,7 @@ function TestRulesCard({ const data = await response.json(); setResult(data); - }, [authMethod, email, oauthProvider, stackAdminApp]); + }, [authMethod, email, oauthProvider, riskScoreBot, riskScoreFreeTrialAbuse, stackAdminApp]); const handleAuthMethodChange = (value: string) => { if (value === 'password' || value === 'otp' || value === 'oauth' || value === 'passkey') { @@ -653,6 +663,35 @@ function TestRulesCard({ +
+
+ + Risk score: bot + + setRiskScoreBot(Math.max(0, Math.min(100, Number(e.target.value) || 0)))} + /> +
+
+ + Risk score: free trial abuse + + setRiskScoreFreeTrialAbuse(Math.max(0, Math.min(100, Number(e.target.value) || 0)))} + /> +
+
+
)} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index d438a6083f..08da04683f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -417,6 +417,12 @@ function UserDetails({ user }: UserDetailsProps) { } name="Signed up at"> + } name="Risk score: bot"> + + + } name="Risk score: free trial abuse"> + + ); diff --git a/apps/dashboard/src/components/rule-builder/condition-builder.tsx b/apps/dashboard/src/components/rule-builder/condition-builder.tsx index e78d9616e2..c634eac715 100644 --- a/apps/dashboard/src/components/rule-builder/condition-builder.tsx +++ b/apps/dashboard/src/components/rule-builder/condition-builder.tsx @@ -59,12 +59,18 @@ const FIELD_OPTIONS: { value: ConditionField, label: string }[] = [ { value: 'emailDomain', label: 'Email Domain' }, { value: 'authMethod', label: 'Auth Method' }, { value: 'oauthProvider', label: 'OAuth Provider' }, + { value: 'riskScores.bot', label: 'Risk Score: Bot' }, + { value: 'riskScores.freeTrialAbuse', label: 'Risk Score: Free Trial Abuse' }, ]; // Operator options with labels const OPERATOR_OPTIONS: { value: ConditionOperator, label: string }[] = [ { value: 'equals', label: 'equals' }, { value: 'not_equals', label: 'does not equal' }, + { value: 'greater_than', label: 'is greater than' }, + { value: 'greater_or_equal', label: 'is greater than or equal' }, + { value: 'less_than', label: 'is less than' }, + { value: 'less_or_equal', label: 'is less than or equal' }, { value: 'contains', label: 'contains' }, { value: 'starts_with', label: 'starts with' }, { value: 'ends_with', label: 'ends with' }, @@ -72,8 +78,15 @@ const OPERATOR_OPTIONS: { value: ConditionOperator, label: string }[] = [ { value: 'in_list', label: 'is one of' }, ]; +function isNumericField(field: ConditionField): boolean { + return field === 'riskScores.bot' || field === 'riskScores.freeTrialAbuse'; +} + // Get available operators for a field function getOperatorsForField(field: ConditionField): ConditionOperator[] { + if (isNumericField(field)) { + return ['equals', 'not_equals', 'greater_than', 'greater_or_equal', 'less_than', 'less_or_equal']; + } if (field === 'authMethod' || field === 'oauthProvider') { return ['equals', 'not_equals', 'in_list']; } @@ -111,7 +124,11 @@ function ConditionRow({ const operator = newOperators.includes(condition.operator) ? condition.operator : newOperators[0]; // Reset value - use array for in_list, string otherwise - const value: string | string[] = operator === 'in_list' ? [] : ''; + const value: string | number | string[] = operator === 'in_list' + ? [] + : isNumericField(field) + ? 0 + : ''; onChange({ ...condition, field, operator, value }); }; @@ -127,7 +144,7 @@ function ConditionRow({ onChange({ ...condition, operator, value }); }; - const handleValueChange = (value: string | string[]) => { + const handleValueChange = (value: string | number | string[]) => { onChange({ ...condition, value }); }; @@ -178,6 +195,17 @@ function ConditionRow({ ))} + ) : isNumericField(condition.field) ? ( + handleValueChange(e.target.value === '' ? 0 : Number(e.target.value))} + placeholder="0-100" + className="h-8 px-2 text-sm bg-background/60 border border-border/50 rounded-md flex-1" + /> ) : (
{ expect(cel).toContain('inject\\"attack.com'); expect(cel).toContain('also\\\\bad.com'); }); + + it('should serialize numeric risk score comparisons', () => { + const greaterThan = visualTreeToCel({ + ...createEmptyCondition(), + field: 'riskScores.bot' as const, + operator: 'greater_than' as const, + value: 80, + }); + const lessOrEqual = visualTreeToCel({ + ...createEmptyCondition(), + field: 'riskScores.freeTrialAbuse' as const, + operator: 'less_or_equal' as const, + value: 40, + }); + + expect(greaterThan).toBe('riskScores.bot > 80'); + expect(lessOrEqual).toBe('riskScores.freeTrialAbuse <= 40'); + }); }); describe('CEL to visual tree parsing', () => { @@ -166,5 +184,15 @@ describe('cel-visual-parser', () => { expect(result.value).toBe('test\\value'); } }); + + it('should parse numeric risk score comparisons', () => { + const result = parseCelToVisualTree('riskScores.bot >= 75'); + expect(result).toBeDefined(); + if (result?.type === 'condition') { + expect(result.field).toBe('riskScores.bot'); + expect(result.operator).toBe('greater_or_equal'); + expect(result.value).toBe(75); + } + }); }); }); diff --git a/apps/dashboard/src/lib/cel-visual-parser.ts b/apps/dashboard/src/lib/cel-visual-parser.ts index fb128a79a9..5dc059abbe 100644 --- a/apps/dashboard/src/lib/cel-visual-parser.ts +++ b/apps/dashboard/src/lib/cel-visual-parser.ts @@ -11,11 +11,16 @@ * - emailDomain == "domain.com" / emailDomain in ["d1", "d2"] * - authMethod == "password" / authMethod in ["password", "otp"] * - oauthProvider == "google" / oauthProvider in ["google", "github"] + * - riskScores.bot > 80 / riskScores.freeTrialAbuse >= 60 */ export type ConditionOperator = | 'equals' | 'not_equals' + | 'greater_than' + | 'greater_or_equal' + | 'less_than' + | 'less_or_equal' | 'matches' // regex | 'ends_with' | 'starts_with' @@ -26,14 +31,20 @@ export type ConditionField = | 'email' | 'emailDomain' | 'authMethod' - | 'oauthProvider'; + | 'oauthProvider' + | 'riskScores.bot' + | 'riskScores.freeTrialAbuse'; + +function isNumericField(field: ConditionField): boolean { + return field === 'riskScores.bot' || field === 'riskScores.freeTrialAbuse'; +} export type ConditionNode = { type: 'condition', id: string, field: ConditionField, operator: ConditionOperator, - value: string | string[], + value: string | number | string[], }; export type GroupNode = { @@ -77,14 +88,34 @@ function unescapeCelString(value: string): string { function conditionToCel(condition: ConditionNode): string { const { field, operator, value } = condition; + const valueAsNumber = typeof value === 'number' ? value : Number(value); + const useNumericValue = isNumericField(field) && Number.isFinite(valueAsNumber); switch (operator) { case 'equals': { + if (useNumericValue) { + return `${field} == ${valueAsNumber}`; + } return `${field} == "${escapeCelString(String(value))}"`; } case 'not_equals': { + if (useNumericValue) { + return `${field} != ${valueAsNumber}`; + } return `${field} != "${escapeCelString(String(value))}"`; } + case 'greater_than': { + return `${field} > ${useNumericValue ? valueAsNumber : 0}`; + } + case 'greater_or_equal': { + return `${field} >= ${useNumericValue ? valueAsNumber : 0}`; + } + case 'less_than': { + return `${field} < ${useNumericValue ? valueAsNumber : 0}`; + } + case 'less_or_equal': { + return `${field} <= ${useNumericValue ? valueAsNumber : 0}`; + } case 'matches': { return `${field}.matches("${escapeCelString(String(value))}")`; } @@ -260,8 +291,80 @@ function splitByOperator(expr: string, operator: string): string[] { function parseCondition(expr: string): ConditionNode | null { const trimmed = expr.trim(); + // Match patterns like: field >= 42 + const greaterOrEqualNumberMatch = trimmed.match(/^([\w.]+)\s*>=\s*(-?\d+(?:\.\d+)?)$/); + if (greaterOrEqualNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: greaterOrEqualNumberMatch[1] as ConditionField, + operator: 'greater_or_equal', + value: Number(greaterOrEqualNumberMatch[2]), + }; + } + + // Match patterns like: field <= 42 + const lessOrEqualNumberMatch = trimmed.match(/^([\w.]+)\s*<=\s*(-?\d+(?:\.\d+)?)$/); + if (lessOrEqualNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: lessOrEqualNumberMatch[1] as ConditionField, + operator: 'less_or_equal', + value: Number(lessOrEqualNumberMatch[2]), + }; + } + + // Match patterns like: field > 42 + const greaterNumberMatch = trimmed.match(/^([\w.]+)\s*>\s*(-?\d+(?:\.\d+)?)$/); + if (greaterNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: greaterNumberMatch[1] as ConditionField, + operator: 'greater_than', + value: Number(greaterNumberMatch[2]), + }; + } + + // Match patterns like: field < 42 + const lessNumberMatch = trimmed.match(/^([\w.]+)\s*<\s*(-?\d+(?:\.\d+)?)$/); + if (lessNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: lessNumberMatch[1] as ConditionField, + operator: 'less_than', + value: Number(lessNumberMatch[2]), + }; + } + + // Match patterns like: field == 42 + const equalsNumberMatch = trimmed.match(/^([\w.]+)\s*==\s*(-?\d+(?:\.\d+)?)$/); + if (equalsNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: equalsNumberMatch[1] as ConditionField, + operator: 'equals', + value: Number(equalsNumberMatch[2]), + }; + } + + // Match patterns like: field != 42 + const notEqualsNumberMatch = trimmed.match(/^([\w.]+)\s*!=\s*(-?\d+(?:\.\d+)?)$/); + if (notEqualsNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: notEqualsNumberMatch[1] as ConditionField, + operator: 'not_equals', + value: Number(notEqualsNumberMatch[2]), + }; + } + // Match patterns like: field == "value" - const equalsMatch = trimmed.match(/^(\w+)\s*==\s*"((?:\\.|[^"\\])*)"$/); + const equalsMatch = trimmed.match(/^([\w.]+)\s*==\s*"((?:\\.|[^"\\])*)"$/); if (equalsMatch) { return { type: 'condition', @@ -273,7 +376,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field != "value" - const notEqualsMatch = trimmed.match(/^(\w+)\s*!=\s*"((?:\\.|[^"\\])*)"$/); + const notEqualsMatch = trimmed.match(/^([\w.]+)\s*!=\s*"((?:\\.|[^"\\])*)"$/); if (notEqualsMatch) { return { type: 'condition', @@ -285,7 +388,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field.matches("regex") - const matchesMatch = trimmed.match(/^(\w+)\.matches\("((?:\\.|[^"\\])*)"\)$/); + const matchesMatch = trimmed.match(/^([\w.]+)\.matches\("((?:\\.|[^"\\])*)"\)$/); if (matchesMatch) { return { type: 'condition', @@ -297,7 +400,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field.endsWith("value") - const endsWithMatch = trimmed.match(/^(\w+)\.endsWith\("((?:\\.|[^"\\])*)"\)$/); + const endsWithMatch = trimmed.match(/^([\w.]+)\.endsWith\("((?:\\.|[^"\\])*)"\)$/); if (endsWithMatch) { return { type: 'condition', @@ -309,7 +412,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field.startsWith("value") - const startsWithMatch = trimmed.match(/^(\w+)\.startsWith\("((?:\\.|[^"\\])*)"\)$/); + const startsWithMatch = trimmed.match(/^([\w.]+)\.startsWith\("((?:\\.|[^"\\])*)"\)$/); if (startsWithMatch) { return { type: 'condition', @@ -321,7 +424,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field.contains("value") - const containsMatch = trimmed.match(/^(\w+)\.contains\("((?:\\.|[^"\\])*)"\)$/); + const containsMatch = trimmed.match(/^([\w.]+)\.contains\("((?:\\.|[^"\\])*)"\)$/); if (containsMatch) { return { type: 'condition', @@ -333,7 +436,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field in ["a", "b", "c"] - const inListMatch = trimmed.match(/^(\w+)\s+in\s+\[([^\]]*)\]$/); + const inListMatch = trimmed.match(/^([\w.]+)\s+in\s+\[([^\]]*)\]$/); if (inListMatch) { const listStr = inListMatch[2]; const items = listStr diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts index 11165a4062..9329235bda 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts @@ -81,4 +81,49 @@ describe("with admin access", () => { action: { type: "reject" }, }); }); + + it("evaluates risk score conditions", async ({ expect }) => { + await Project.createAndSwitch(); + await Project.updateConfig({ + "auth.signUpRules.block-high-bot-score": { + enabled: true, + displayName: "Block high bot score", + priority: 1, + condition: "riskScores.bot >= 80", + action: { + type: "reject", + message: "High bot risk", + }, + }, + "auth.signUpRulesDefaultAction": "allow", + }); + + const response = await niceBackendFetch("/api/v1/internal/sign-up-rules-test", { + method: "POST", + accessType: "admin", + body: { + email: "risk@example.com", + auth_method: "password", + risk_scores: { + bot: 90, + free_trial_abuse: 10, + }, + }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + context: { + risk_scores: { + bot: 90, + free_trial_abuse: 10, + }, + }, + outcome: { + should_allow: false, + decision: "reject", + decision_rule_id: "block-high-bot-score", + }, + }); + }); }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts new file mode 100644 index 0000000000..0def723e07 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts @@ -0,0 +1,569 @@ +import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; +import { describe } from "vitest"; +import { it } from "../../../../helpers"; +import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../backend-helpers"; + +describe("risk scores", () => { + // ========================================== + // PERSISTENCE ON SIGNUP + // ========================================== + + describe("persistence on password signup", () => { + it("should persist non-zero risk scores for high-risk email (test@example.com stub)", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: "test@example.com", + password: generateSecureRandomString(), + }, + }); + expect(response.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${response.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 100, + free_trial_abuse: 100, + }, + }); + }); + + it("should persist zero risk scores for normal signups", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + expect(res.signUpResponse.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + }); + + describe("persistence on OTP signup", () => { + it("should persist risk scores for OTP signup", async ({ expect }) => { + await Project.createAndSwitch({ + config: { magic_link_enabled: true }, + }); + + await Auth.Otp.signIn(); + + const meResponse = await niceBackendFetch("/api/v1/users/me", { + accessType: "server", + }); + expect(meResponse.status).toBe(200); + + expect(meResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + }); + + describe("persistence on OAuth signup", () => { + it("should persist risk scores for OAuth signup", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + }, + }); + await InternalApiKey.createAndSetProjectKeys(); + + const response = await Auth.OAuth.signIn(); + expect(response.tokenResponse.status).toBe(200); + + const meResponse = await niceBackendFetch("/api/v1/users/me", { + accessType: "server", + }); + expect(meResponse.status).toBe(200); + + expect(meResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + }); + + describe("persistence on anonymous user conversion", () => { + it("should persist risk scores when converting anonymous user to password user", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const anonResponse = await niceBackendFetch("/api/v1/auth/anonymous/sign-up", { + accessType: "client", + method: "POST", + body: {}, + }); + expect(anonResponse.status).toBe(200); + + const accessToken = anonResponse.body.access_token; + + const convertResponse = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + headers: { "x-stack-access-token": accessToken }, + body: { + email: `convert-${generateSecureRandomString(8)}@example.com`, + password: generateSecureRandomString(), + }, + }); + expect(convertResponse.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${convertResponse.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + + it("should persist non-zero risk scores when converting anonymous user with high-risk email", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const anonResponse = await niceBackendFetch("/api/v1/auth/anonymous/sign-up", { + accessType: "client", + method: "POST", + body: {}, + }); + expect(anonResponse.status).toBe(200); + + const accessToken = anonResponse.body.access_token; + + const convertResponse = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + headers: { "x-stack-access-token": accessToken }, + body: { + email: "test@example.com", + password: generateSecureRandomString(), + }, + }); + expect(convertResponse.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${convertResponse.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 100, + free_trial_abuse: 100, + }, + }); + }); + }); + + // ========================================== + // ANONYMOUS USERS + // ========================================== + + describe("anonymous users", () => { + it("should have zero risk scores for anonymous users (rules not evaluated)", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const anonResponse = await niceBackendFetch("/api/v1/auth/anonymous/sign-up", { + accessType: "client", + method: "POST", + body: {}, + }); + expect(anonResponse.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${anonResponse.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + }); + + // ========================================== + // SERVER API VISIBILITY + // ========================================== + + describe("API visibility", () => { + it("should include risk_scores in server user response", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const serverResponse = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "GET", + accessType: "server", + }); + + expect(serverResponse.status).toBe(200); + expect(serverResponse.body).toHaveProperty("risk_scores"); + expect(serverResponse.body.risk_scores).toHaveProperty("sign_up"); + expect(serverResponse.body.risk_scores.sign_up).toHaveProperty("bot"); + expect(serverResponse.body.risk_scores.sign_up).toHaveProperty("free_trial_abuse"); + }); + + it("should NOT include risk_scores in client user response", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + await Auth.Password.signUpWithEmail(); + + const clientResponse = await niceBackendFetch("/api/v1/users/me", { + accessType: "client", + }); + + expect(clientResponse.status).toBe(200); + expect(clientResponse.body).not.toHaveProperty("risk_scores"); + }); + + it("should include risk_scores in user list response (server access)", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + await Auth.Password.signUpWithEmail(); + + const listResponse = await niceBackendFetch("/api/v1/users", { + accessType: "server", + }); + + expect(listResponse.status).toBe(200); + expect(listResponse.body.items.length).toBeGreaterThan(0); + expect(listResponse.body.items[0]).toHaveProperty("risk_scores"); + expect(listResponse.body.items[0].risk_scores.sign_up).toHaveProperty("bot"); + expect(listResponse.body.items[0].risk_scores.sign_up).toHaveProperty("free_trial_abuse"); + }); + }); + + // ========================================== + // SERVER-SIDE UPDATE + // ========================================== + + describe("server-side update", () => { + it("should allow server to update risk scores", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const updateResponse = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "PATCH", + accessType: "server", + body: { + risk_scores: { + sign_up: { + bot: 75, + free_trial_abuse: 30, + }, + }, + }, + }); + + expect(updateResponse.status).toBe(200); + expect(updateResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 75, + free_trial_abuse: 30, + }, + }); + + const readResponse = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "GET", + accessType: "server", + }); + expect(readResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 75, + free_trial_abuse: 30, + }, + }); + }); + + it("should reject risk scores out of range (> 100)", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const response = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "PATCH", + accessType: "server", + body: { + risk_scores: { + sign_up: { + bot: 150, + free_trial_abuse: 0, + }, + }, + }, + }); + + expect(response.status).toBe(400); + }); + + it("should reject risk scores out of range (< 0)", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const response = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "PATCH", + accessType: "server", + body: { + risk_scores: { + sign_up: { + bot: -10, + free_trial_abuse: 0, + }, + }, + }, + }); + + expect(response.status).toBe(400); + }); + + it("should reject non-integer risk scores", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const response = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "PATCH", + accessType: "server", + body: { + risk_scores: { + sign_up: { + bot: 50.5, + free_trial_abuse: 0, + }, + }, + }, + }); + + expect(response.status).toBe(400); + }); + + it("should not change risk scores when updating other user fields", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: "test@example.com", + password: generateSecureRandomString(), + }, + }); + expect(response.status).toBe(200); + + const userId = response.body.user_id; + + const updateResponse = await niceBackendFetch(`/api/v1/users/${userId}`, { + method: "PATCH", + accessType: "server", + body: { + display_name: "Updated Name", + }, + }); + expect(updateResponse.status).toBe(200); + + expect(updateResponse.body.display_name).toBe("Updated Name"); + expect(updateResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 100, + free_trial_abuse: 100, + }, + }); + }); + }); + + // ========================================== + // RISK SCORES + SIGN-UP RULES INTERACTION + // ========================================== + + describe("interaction with sign-up rules", () => { + it("should restrict user based on risk score CEL condition", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + await Project.updateConfig({ + 'auth.signUpRules.restrict-high-bot': { + enabled: true, + displayName: 'Restrict high bot score', + priority: 0, + condition: 'riskScores.bot >= 80', + action: { type: 'restrict' }, + }, + 'auth.signUpRulesDefaultAction': 'allow', + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: "test@example.com", + password: generateSecureRandomString(), + }, + }); + expect(response.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${response.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.body.restricted_by_admin).toBe(true); + expect(userResponse.body.risk_scores.sign_up.bot).toBe(100); + }); + + it("should reject user based on risk score CEL condition", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + await Project.updateConfig({ + 'auth.signUpRules.reject-high-risk': { + enabled: true, + displayName: 'Reject high risk', + priority: 0, + condition: 'riskScores.bot >= 80 && riskScores.freeTrialAbuse >= 80', + action: { type: 'reject' }, + }, + 'auth.signUpRulesDefaultAction': 'allow', + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: "test@example.com", + password: generateSecureRandomString(), + }, + }); + + expect(response.status).toBe(403); + expect(response.body.code).toBe("SIGN_UP_REJECTED"); + }); + + it("should allow user when risk score is below threshold", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + await Project.updateConfig({ + 'auth.signUpRules.reject-high-bot': { + enabled: true, + displayName: 'Reject high bot score', + priority: 0, + condition: 'riskScores.bot >= 80', + action: { type: 'reject' }, + }, + 'auth.signUpRulesDefaultAction': 'allow', + }); + + const res = await Auth.Password.signUpWithEmail(); + expect(res.signUpResponse.status).toBe(200); + }); + }); + + // ========================================== + // SERVER-CREATED USERS + // ========================================== + + describe("server-created users", () => { + it("should default risk scores to 0 for server-created users", async ({ expect }) => { + const createResponse = await niceBackendFetch("/api/v1/users", { + method: "POST", + accessType: "server", + body: { + primary_email: `server-created-${generateSecureRandomString(8)}@example.com`, + }, + }); + + expect(createResponse.status).toBe(201); + expect(createResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + + it("should allow setting risk scores when creating users via server API", async ({ expect }) => { + const createResponse = await niceBackendFetch("/api/v1/users", { + method: "POST", + accessType: "server", + body: { + primary_email: `risky-${generateSecureRandomString(8)}@example.com`, + risk_scores: { + sign_up: { + bot: 55, + free_trial_abuse: 42, + }, + }, + }, + }); + + expect(createResponse.status).toBe(201); + expect(createResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 55, + free_trial_abuse: 42, + }, + }); + }); + }); +}); diff --git a/packages/stack-shared/src/interface/crud/users.ts b/packages/stack-shared/src/interface/crud/users.ts index cbeb164370..bd15329668 100644 --- a/packages/stack-shared/src/interface/crud/users.ts +++ b/packages/stack-shared/src/interface/crud/users.ts @@ -23,6 +23,12 @@ export const usersCrudServerUpdateSchema = fieldSchema.yupObject({ restricted_by_admin: fieldSchema.yupBoolean().optional().meta({ openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }), restricted_by_admin_reason: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }), restricted_by_admin_private_details: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }), + risk_scores: fieldSchema.yupObject({ + sign_up: fieldSchema.yupObject({ + bot: fieldSchema.yupNumber().integer().min(0).max(100).defined(), + free_trial_abuse: fieldSchema.yupNumber().integer().min(0).max(100).defined(), + }).defined(), + }).optional(), }).defined().test( "restricted_by_admin_consistency", "When restricted_by_admin is not true, reason and private_details must be null", @@ -64,6 +70,12 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({ restricted_by_admin: fieldSchema.yupBoolean().defined().meta({ openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }), restricted_by_admin_reason: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }), restricted_by_admin_private_details: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }), + risk_scores: fieldSchema.yupObject({ + sign_up: fieldSchema.yupObject({ + bot: fieldSchema.yupNumber().min(0).max(100).integer().defined(), + free_trial_abuse: fieldSchema.yupNumber().min(0).max(100).integer().defined(), + }).defined(), + }).defined().meta({ openapiField: { description: 'User risk scores used for sign-up risk evaluation.', exampleValue: { sign_up: { bot: 0, free_trial_abuse: 0 } } } }), oauth_providers: fieldSchema.yupArray(fieldSchema.yupObject({ id: fieldSchema.yupString().defined(), @@ -107,6 +119,12 @@ export const usersCrudServerCreateSchema = usersCrudServerUpdateSchema.omit(['se email: fieldSchema.yupString().nullable().defined().default(null), }).defined()).optional().meta({ openapiField: { hidden: true } }), is_anonymous: fieldSchema.yupBoolean().optional(), + risk_scores: fieldSchema.yupObject({ + sign_up: fieldSchema.yupObject({ + bot: fieldSchema.yupNumber().integer().min(0).max(100).defined(), + free_trial_abuse: fieldSchema.yupNumber().integer().min(0).max(100).defined(), + }).defined(), + }).optional(), }).defined()); export const usersCrudServerDeleteSchema = fieldSchema.yupMixed(); diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index c0f98f86ba..245f9f13ab 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -562,6 +562,12 @@ export class _StackServerAppImplIncomplete): Promise, From 14f5b2237dd7adc8b71054ef43bed06deb7435de Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 9 Mar 2026 12:27:34 -0700 Subject: [PATCH 02/77] COUNTRY CODE (1) --- .../migration.sql | 2 + .../tests/default-null.ts | 48 +++++ apps/backend/prisma/schema.prisma | 1 + .../oauth/callback/[provider_id]/route.tsx | 14 +- .../oauth/callback/apple/native/route.tsx | 9 +- .../otp/sign-in/verification-code-handler.tsx | 3 + .../latest/auth/password/sign-up/route.tsx | 3 + .../internal/sign-up-rules-test/route.tsx | 8 +- .../backend/src/app/api/latest/users/crud.tsx | 4 + apps/backend/src/lib/cel-evaluator.ts | 51 ++++- apps/backend/src/lib/end-users.tsx | 7 +- apps/backend/src/lib/oauth.tsx | 12 +- apps/backend/src/lib/risk-scores.tsx | 2 +- apps/backend/src/lib/users.tsx | 30 ++- .../[projectId]/sign-up-rules/page-client.tsx | 27 ++- .../users/[userId]/page-client.tsx | 5 +- .../rule-builder/condition-builder.tsx | 156 ++++++++------ .../src/lib/cel-visual-parser.test.ts | 36 ++++ apps/dashboard/src/lib/cel-visual-parser.ts | 26 ++- .../api/v1/auth/sign-up-rules.test.ts | 196 +++++++++++++++++- .../v1/internal/sign-up-rules-test.test.ts | 100 +++++++++ .../endpoints/api/v1/risk-scores.test.ts | 155 +++++++++++++- claude/CLAUDE-KNOWLEDGE.md | 15 ++ examples/demo/src/app/page-client.tsx | 15 +- .../stack-shared/src/interface/crud/users.ts | 2 + .../apps/implementations/server-app-impl.ts | 1 + .../template/src/lib/stack-app/users/index.ts | 2 + 27 files changed, 832 insertions(+), 98 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260309000000_add_project_user_country_code/migration.sql create mode 100644 apps/backend/prisma/migrations/20260309000000_add_project_user_country_code/tests/default-null.ts diff --git a/apps/backend/prisma/migrations/20260309000000_add_project_user_country_code/migration.sql b/apps/backend/prisma/migrations/20260309000000_add_project_user_country_code/migration.sql new file mode 100644 index 0000000000..a8aaf34a75 --- /dev/null +++ b/apps/backend/prisma/migrations/20260309000000_add_project_user_country_code/migration.sql @@ -0,0 +1,2 @@ +-- Add nullable sign-up country code to ProjectUser +ALTER TABLE "ProjectUser" ADD COLUMN "countryCode" TEXT; diff --git a/apps/backend/prisma/migrations/20260309000000_add_project_user_country_code/tests/default-null.ts b/apps/backend/prisma/migrations/20260309000000_add_project_user_country_code/tests/default-null.ts new file mode 100644 index 0000000000..4be59d1c98 --- /dev/null +++ b/apps/backend/prisma/migrations/20260309000000_add_project_user_country_code/tests/default-null.ts @@ -0,0 +1,48 @@ +import { randomUUID } from 'crypto'; +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +export const preMigration = 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", + "signUpRiskScoreBot", + "signUpRiskScoreFreeTrialAbuse" + ) VALUES ( + ${userId}::uuid, + ${tenancyId}::uuid, + ${projectId}, + 'main', + NOW(), + NOW(), + NOW(), + 0, + 0 + ) + `; + + return { userId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const rows = await sql` + SELECT "countryCode" + FROM "ProjectUser" + WHERE "projectUserId" = ${ctx.userId}::uuid + `; + + expect(rows).toHaveLength(1); + expect(rows[0].countryCode).toBeNull(); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index cc54d874ce..dbab4ab466 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -224,6 +224,7 @@ model ProjectUser { // Sign-up risk scores (0-100, set at sign-up time) signUpRiskScoreBot Int @db.SmallInt signUpRiskScoreFreeTrialAbuse Int @db.SmallInt + countryCode String? projectUserOAuthAccounts ProjectUserOAuthAccount[] teamMembers TeamMember[] diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index fbac9c757e..76920489d5 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -24,7 +24,7 @@ async function createProjectUserOAuthAccountForLink(prisma: PrismaClientTransact tenancyId: string, providerId: string, providerAccountId: string, - email?: string | null, + email: string | null, projectUserId: string, }) { return await prisma.projectUserOAuthAccount.create({ @@ -248,7 +248,7 @@ const handler = createSmartRouteHandler({ tenancyId: outerInfo.tenancyId, providerId: provider.id, providerAccountId: userInfo.accountId, - email: userInfo.email, + email: userInfo.email ?? null, projectUserId, }); @@ -288,7 +288,7 @@ const handler = createSmartRouteHandler({ tenancyId: outerInfo.tenancyId, providerId: provider.id, providerAccountId: userInfo.accountId, - email: userInfo.email ?? undefined, + email: userInfo.email ?? null, projectUserId: linkedUserId, }); @@ -327,15 +327,17 @@ const handler = createSmartRouteHandler({ { providerId: provider.id, providerAccountId: userInfo.accountId, - email: userInfo.email ?? undefined, + email: userInfo.email ?? null, emailVerified: userInfo.emailVerified, primaryEmailAuthEnabled, currentUser, - displayName: userInfo.displayName ?? undefined, - profileImageUrl: userInfo.profileImageUrl ?? undefined, + displayName: userInfo.displayName ?? null, + profileImageUrl: userInfo.profileImageUrl ?? null, signUpRuleOptions: { authMethod: 'oauth', oauthProvider: provider.id, + ipAddress: null, + countryCode: null, // Note: Request context not easily available in OAuth callback // TODO: Pass IP and user agent from stored OAuth state if needed }, diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx index a4d1071349..57cd63ee04 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -18,7 +18,7 @@ const appleJWKS = createRemoteJWKSet(new URL("https://appleid.apple.com/auth/key */ async function verifyAppleIdToken(idToken: string, allowedBundleIds: string[]): Promise<{ sub: string, - email?: string, + email: string | null, emailVerified: boolean, }> { try { @@ -29,7 +29,7 @@ async function verifyAppleIdToken(idToken: string, allowedBundleIds: string[]): return { sub: payload.sub ?? throwErr("No sub claim in Apple ID token"), - email: typeof payload.email === "string" ? payload.email : undefined, + email: typeof payload.email === "string" ? payload.email : null, emailVerified: payload.email_verified === true || payload.email_verified === "true", }; } catch (error) { @@ -125,9 +125,14 @@ export const POST = createSmartRouteHandler({ email: appleUser.email, emailVerified: appleUser.emailVerified, primaryEmailAuthEnabled, + currentUser: null, + displayName: null, + profileImageUrl: null, signUpRuleOptions: { authMethod: 'oauth', oauthProvider: 'apple', + ipAddress: null, + countryCode: null, // Note: Request context not easily available in native OAuth callback }, }); diff --git a/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx index f16ada136c..0048b7f704 100644 --- a/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx @@ -119,6 +119,9 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ [], { authMethod: 'otp', + oauthProvider: null, + ipAddress: null, + countryCode: null, // TODO: Pass request context when available in verification code handler } ); diff --git a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx index a97a128527..8edb033f7c 100644 --- a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx +++ b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx @@ -66,6 +66,9 @@ export const POST = createSmartRouteHandler({ [KnownErrors.UserWithEmailAlreadyExists], { authMethod: 'password', + oauthProvider: null, + ipAddress: null, + countryCode: null, } ); diff --git a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx index 090480fc21..3e494f43b0 100644 --- a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx +++ b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx @@ -18,9 +18,10 @@ export const POST = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }), body: yupObject({ - email: yupString().optional(), + email: yupString().nullable().defined(), + country_code: yupString().nullable().defined(), auth_method: yupString().oneOf(AUTH_METHODS).defined(), - oauth_provider: yupString().optional(), + oauth_provider: yupString().nullable().defined(), risk_scores: yupObject({ bot: yupNumber().min(0).max(100).integer().defined(), free_trial_abuse: yupNumber().min(0).max(100).integer().defined(), @@ -34,6 +35,7 @@ export const POST = createSmartRouteHandler({ context: yupObject({ email: yupString().defined(), email_domain: yupString().defined(), + country_code: yupString().defined(), auth_method: yupString().oneOf(AUTH_METHODS).defined(), oauth_provider: yupString().defined(), risk_scores: yupObject({ @@ -64,6 +66,7 @@ export const POST = createSmartRouteHandler({ handler: async (req) => { const context = createSignUpRuleContext({ email: req.body.email, + countryCode: req.body.country_code, authMethod: req.body.auth_method, oauthProvider: req.body.oauth_provider, riskScores: { @@ -80,6 +83,7 @@ export const POST = createSmartRouteHandler({ context: { email: context.email, email_domain: context.emailDomain, + country_code: context.countryCode, auth_method: context.authMethod, oauth_provider: context.oauthProvider, risk_scores: { diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 9428795e8c..db47a20071 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -184,6 +184,7 @@ export const userPrismaToCrud = ( restricted_by_admin: prisma.restrictedByAdmin, restricted_by_admin_reason: prisma.restrictedByAdminReason, restricted_by_admin_private_details: prisma.restrictedByAdminPrivateDetails, + country_code: prisma.countryCode, risk_scores: { sign_up: { bot: prisma.signUpRiskScoreBot, @@ -408,6 +409,7 @@ 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, + country_code: row.countryCode, risk_scores: { sign_up: { bot: row.signUpRiskScoreBot, @@ -654,6 +656,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC restrictedByAdmin, restrictedByAdminReason, restrictedByAdminPrivateDetails, + countryCode: data.country_code, signUpRiskScoreBot: data.risk_scores?.sign_up.bot ?? 0, signUpRiskScoreFreeTrialAbuse: data.risk_scores?.sign_up.free_trial_abuse ?? 0, }, @@ -1158,6 +1161,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC restrictedByAdmin: data.restricted_by_admin ?? undefined, restrictedByAdminReason: restrictedByAdminReason, restrictedByAdminPrivateDetails: restrictedByAdminPrivateDetails, + countryCode: data.country_code, signUpRiskScoreBot: data.risk_scores?.sign_up.bot, signUpRiskScoreFreeTrialAbuse: data.risk_scores?.sign_up.free_trial_abuse, }), diff --git a/apps/backend/src/lib/cel-evaluator.ts b/apps/backend/src/lib/cel-evaluator.ts index 970bd32b21..e08351166e 100644 --- a/apps/backend/src/lib/cel-evaluator.ts +++ b/apps/backend/src/lib/cel-evaluator.ts @@ -12,7 +12,7 @@ export class CelEvaluationError extends Error { constructor( message: string, public readonly expression: string, - public readonly cause?: unknown + public readonly cause: unknown | null = null, ) { super(message); this.name = 'CelEvaluationError'; @@ -29,6 +29,8 @@ export type SignUpRuleContext = { email: string, /** Domain part of email (after @) */ emailDomain: string, + /** Best-effort ISO 3166-1 alpha-2 country code derived from request geo headers */ + countryCode: string, /** Authentication method: "password", "otp", "oauth", "passkey" */ authMethod: 'password' | 'otp' | 'oauth' | 'passkey', /** OAuth provider ID if authMethod is "oauth", empty string otherwise */ @@ -159,9 +161,10 @@ export function evaluateCelExpression( * @returns SignUpRuleContext ready for CEL evaluation */ export function createSignUpRuleContext(params: { - email?: string, + email: string | null, + countryCode: string | null, authMethod: 'password' | 'otp' | 'oauth' | 'passkey', - oauthProvider?: string, + oauthProvider: string | null, riskScores: SignUpRiskScores, }): SignUpRuleContext { // Handle missing email (e.g., OAuth providers that don't return email) @@ -169,18 +172,21 @@ export function createSignUpRuleContext(params: { let email = ''; let emailDomain = ''; - if (params.email) { + if (params.email !== null && params.email !== '') { // Normalize email to match how it's stored in the database email = normalizeEmail(params.email); // Extract domain from normalized email emailDomain = email.includes('@') ? (email.split('@').pop() ?? '') : ''; } + const countryCode = params.countryCode === null ? '' : params.countryCode.trim().toUpperCase(); + return { email, emailDomain, + countryCode, authMethod: params.authMethod, - oauthProvider: params.oauthProvider ?? '', + oauthProvider: params.oauthProvider === null ? '' : params.oauthProvider, riskScores: params.riskScores, }; } @@ -190,7 +196,9 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { // Should normalize email expect(createSignUpRuleContext({ email: 'Test.User@Example.COM', + countryCode: null, authMethod: 'password', + oauthProvider: null, riskScores: { bot: 17, freeTrialAbuse: 23, @@ -198,6 +206,7 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { })).toEqual({ email: 'test.user@example.com', emailDomain: 'example.com', + countryCode: '', authMethod: 'password', oauthProvider: '', riskScores: { @@ -208,7 +217,8 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { // Should handle missing email (OAuth providers without email) expect(createSignUpRuleContext({ - email: undefined, + email: null, + countryCode: null, authMethod: 'oauth', oauthProvider: 'discord', riskScores: { @@ -218,6 +228,7 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { })).toEqual({ email: '', emailDomain: '', + countryCode: '', authMethod: 'oauth', oauthProvider: 'discord', riskScores: { @@ -229,6 +240,7 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { // Should handle empty string email expect(createSignUpRuleContext({ email: '', + countryCode: null, authMethod: 'oauth', oauthProvider: 'twitter', riskScores: { @@ -238,6 +250,7 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { })).toEqual({ email: '', emailDomain: '', + countryCode: '', authMethod: 'oauth', oauthProvider: 'twitter', riskScores: { @@ -249,6 +262,7 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { // Should handle OAuth with email expect(createSignUpRuleContext({ email: 'oauth.user@gmail.com', + countryCode: null, authMethod: 'oauth', oauthProvider: 'google', riskScores: { @@ -258,6 +272,7 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { })).toEqual({ email: 'oauth.user@gmail.com', emailDomain: 'gmail.com', + countryCode: '', authMethod: 'oauth', oauthProvider: 'google', riskScores: { @@ -265,12 +280,34 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { freeTrialAbuse: 9, }, }); + + expect(createSignUpRuleContext({ + email: 'user@example.com', + countryCode: 'us', + authMethod: 'password', + oauthProvider: null, + riskScores: { + bot: 3, + freeTrialAbuse: 4, + }, + })).toEqual({ + email: 'user@example.com', + emailDomain: 'example.com', + countryCode: 'US', + authMethod: 'password', + oauthProvider: '', + riskScores: { + bot: 3, + freeTrialAbuse: 4, + }, + }); }); import.meta.vitest?.test('evaluateCelExpression with missing email', async ({ expect }) => { // When email is empty, email-based conditions should not match const context = createSignUpRuleContext({ - email: undefined, + email: null, + countryCode: null, authMethod: 'oauth', oauthProvider: 'discord', riskScores: { diff --git a/apps/backend/src/lib/end-users.tsx b/apps/backend/src/lib/end-users.tsx index 5dc301e701..bf2c016813 100644 --- a/apps/backend/src/lib/end-users.tsx +++ b/apps/backend/src/lib/end-users.tsx @@ -54,7 +54,12 @@ type EndUserLocation = { export async function getSpoofableEndUserLocation(): Promise { const endUserInfo = await getEndUserInfo(); - return endUserInfo?.maybeSpoofed === false ? pick(endUserInfo.exactInfo, ["countryCode", "regionCode", "cityName", "latitude", "longitude", "tzIdentifier"]) : null; + if (!endUserInfo) { + return null; + } + + const locationInfo = endUserInfo.maybeSpoofed ? endUserInfo.spoofedInfo : endUserInfo.exactInfo; + return pick(locationInfo, ["countryCode", "regionCode", "cityName", "latitude", "longitude", "tzIdentifier"]); } diff --git a/apps/backend/src/lib/oauth.tsx b/apps/backend/src/lib/oauth.tsx index 98cf315af2..3b796771a6 100644 --- a/apps/backend/src/lib/oauth.tsx +++ b/apps/backend/src/lib/oauth.tsx @@ -131,7 +131,7 @@ export async function linkOAuthAccountToUser( tenancyId: string, providerId: string, providerAccountId: string, - email?: string, + email: string | null, projectUserId: string, } ): Promise<{ oauthAccountId: string }> { @@ -189,12 +189,12 @@ export async function createOAuthUserAndAccount( params: { providerId: string, providerAccountId: string, - email?: string, + email: string | null, emailVerified: boolean, primaryEmailAuthEnabled: boolean, - currentUser?: UsersCrud["Admin"]["Read"] | null, - displayName?: string, - profileImageUrl?: string, + currentUser: UsersCrud["Admin"]["Read"] | null, + displayName: string | null, + profileImageUrl: string | null, signUpRuleOptions: SignUpRuleOptions, } ): Promise<{ projectUserId: string, oauthAccountId: string }> { @@ -206,7 +206,7 @@ export async function createOAuthUserAndAccount( // Create new user (or upgrade anonymous user) with sign-up rule evaluation const newUser = await createOrUpgradeAnonymousUserWithRules( tenancy, - params.currentUser ?? null, + params.currentUser, { display_name: params.displayName, profile_image_url: params.profileImageUrl, diff --git a/apps/backend/src/lib/risk-scores.tsx b/apps/backend/src/lib/risk-scores.tsx index d890578b52..84e0799b35 100644 --- a/apps/backend/src/lib/risk-scores.tsx +++ b/apps/backend/src/lib/risk-scores.tsx @@ -9,7 +9,7 @@ export type SignUpRiskScoreContext = { primaryEmail: string | null, primaryEmailVerified: boolean, authMethod: 'password' | 'otp' | 'oauth' | 'passkey', - oauthProvider?: string, + oauthProvider: string | null, ipAddress: string | null, }; diff --git a/apps/backend/src/lib/users.tsx b/apps/backend/src/lib/users.tsx index a9df28dfb2..524a2e0f63 100644 --- a/apps/backend/src/lib/users.tsx +++ b/apps/backend/src/lib/users.tsx @@ -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 { getSpoofableEndUserIp, getSpoofableEndUserLocation } from "./end-users"; import { calculateSignUpRiskScores } from "./risk-scores"; import { evaluateSignUpRules } from "./sign-up-rules"; import { Tenancy } from "./tenancies"; @@ -12,10 +13,20 @@ import { Tenancy } from "./tenancies"; */ export type SignUpRuleOptions = { authMethod: 'password' | 'otp' | 'oauth' | 'passkey', - oauthProvider?: string, + oauthProvider: string | null, ipAddress: string | null, + countryCode: string | null, }; +function getStubSignUpCountryCode(email: string | null): string | null { + if (email === null) { + return null; + } + + const match = email.match(/^([a-z]{2})-test@example\.com$/); + return match === null ? null : match[1].toUpperCase(); +} + /** * Creates or upgrades an anonymous user with sign-up rule evaluation. * @@ -44,19 +55,27 @@ export async function createOrUpgradeAnonymousUserWithRules( allowedErrorTypes: (new (...args: any) => any)[], signUpRuleOptions: SignUpRuleOptions, ): Promise { - const email = createOrUpdate.primary_email ?? currentUser?.primary_email ?? undefined; + const email = createOrUpdate.primary_email ?? currentUser?.primary_email ?? null; const primaryEmailVerified = createOrUpdate.primary_email_verified ?? currentUser?.primary_email_verified ?? false; + const [requestIpAddress, requestLocation] = await Promise.all([ + signUpRuleOptions.ipAddress !== null ? Promise.resolve(signUpRuleOptions.ipAddress) : getSpoofableEndUserIp().then((ip) => ip ?? null), + signUpRuleOptions.countryCode !== null ? Promise.resolve(null) : getSpoofableEndUserLocation(), + ]); + const countryCode = signUpRuleOptions.countryCode !== null + ? signUpRuleOptions.countryCode + : (requestLocation?.countryCode ?? getStubSignUpCountryCode(email)); const riskScores = await calculateSignUpRiskScores(tenancy, { primaryEmail: email ?? null, primaryEmailVerified, authMethod: signUpRuleOptions.authMethod, oauthProvider: signUpRuleOptions.oauthProvider, - ipAddress: signUpRuleOptions.ipAddress, + ipAddress: requestIpAddress, }); const ruleResult = await evaluateSignUpRules(tenancy, createSignUpRuleContext({ email, + countryCode, authMethod: signUpRuleOptions.authMethod, oauthProvider: signUpRuleOptions.oauthProvider, riskScores, @@ -73,14 +92,15 @@ export async function createOrUpgradeAnonymousUserWithRules( : ""; const restrictionPrivateDetails = restrictionRuleId ? `Restricted by sign-up rule: ${restrictionRuleId}${restrictionRuleDisplayName ? ` (${restrictionRuleDisplayName})` : ""}` - : undefined; + : null; const enrichedCreateOrUpdate = { ...createOrUpdate, ...!!ruleResult.restrictedBecauseOfSignUpRuleId ? { restricted_by_admin: true, - restricted_by_admin_private_details: existingRestrictionPrivateDetails ? `${existingRestrictionPrivateDetails}\n\n${restrictionPrivateDetails}` : restrictionPrivateDetails, + restricted_by_admin_private_details: existingRestrictionPrivateDetails != null ? `${existingRestrictionPrivateDetails}\n\n${restrictionPrivateDetails}` : restrictionPrivateDetails, } : {}, + country_code: countryCode, risk_scores: { sign_up: { bot: riskScores.bot, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx index 60de270c82..21c53817a5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -85,6 +85,7 @@ type SignUpRulesTestResult = { context: { email: string, email_domain: string, + country_code: string, auth_method: 'password' | 'otp' | 'oauth' | 'passkey', oauth_provider: string, risk_scores: { @@ -523,6 +524,7 @@ function TestRulesCard({ stackAdminApp: ReturnType, }) { const [email, setEmail] = useState(''); + const [countryCode, setCountryCode] = useState(''); const [authMethod, setAuthMethod] = useState('password'); const [oauthProvider, setOauthProvider] = useState(''); const [riskScoreBot, setRiskScoreBot] = useState(0); @@ -535,9 +537,12 @@ function TestRulesCard({ { method: 'POST', body: JSON.stringify({ - email: email || undefined, + email: email === '' ? null : email, + country_code: countryCode === '' ? null : countryCode, auth_method: authMethod, - oauth_provider: authMethod === 'oauth' ? (oauthProvider || undefined) : undefined, + oauth_provider: authMethod === 'oauth' + ? (oauthProvider === '' ? null : oauthProvider) + : null, risk_scores: { bot: riskScoreBot, free_trial_abuse: riskScoreFreeTrialAbuse, @@ -556,7 +561,7 @@ function TestRulesCard({ const data = await response.json(); setResult(data); - }, [authMethod, email, oauthProvider, riskScoreBot, riskScoreFreeTrialAbuse, stackAdminApp]); + }, [authMethod, countryCode, email, oauthProvider, riskScoreBot, riskScoreFreeTrialAbuse, stackAdminApp]); const handleAuthMethodChange = (value: string) => { if (value === 'password' || value === 'otp' || value === 'oauth' || value === 'passkey') { @@ -627,6 +632,16 @@ function TestRulesCard({ placeholder="user@company.com" />
+
+ + Country code + + setCountryCode(e.target.value.toUpperCase())} + placeholder="US" + /> +
Auth method @@ -703,6 +718,9 @@ function TestRulesCard({ Simulate a sign-up request to preview which rules trigger. + + Country matching uses best-effort proxy geolocation headers when they are available. +
@@ -840,6 +858,9 @@ function TestRulesCard({ Email domain: {result.context.email_domain || "(empty)"} + + Country code: {result.context.country_code || "(empty)"} + OAuth provider: {result.context.oauth_provider || "(empty)"} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index 08da04683f..c81f99e9b9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -42,7 +42,7 @@ import { useToast } from "@/components/ui"; import { DeleteUserDialog, ImpersonateUserDialog } from "@/components/user-dialogs"; -import { AtIcon, CalendarIcon, CheckIcon, DotsThreeIcon, EnvelopeIcon, HashIcon, ProhibitIcon, ShieldIcon, SquareIcon, XIcon } from "@phosphor-icons/react"; +import { AtIcon, CalendarIcon, CheckIcon, DotsThreeIcon, EnvelopeIcon, GlobeIcon, HashIcon, ProhibitIcon, ShieldIcon, SquareIcon, XIcon } from "@phosphor-icons/react"; import { ServerContactChannel, ServerOAuthProvider, ServerUser } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared"; import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; @@ -420,6 +420,9 @@ function UserDetails({ user }: UserDetailsProps) { } name="Risk score: bot"> + } name="Sign-up country code"> + + } name="Risk score: free trial abuse"> diff --git a/apps/dashboard/src/components/rule-builder/condition-builder.tsx b/apps/dashboard/src/components/rule-builder/condition-builder.tsx index c634eac715..cf29819e89 100644 --- a/apps/dashboard/src/components/rule-builder/condition-builder.tsx +++ b/apps/dashboard/src/components/rule-builder/condition-builder.tsx @@ -56,6 +56,7 @@ export function isConditionTreeValid(node: RuleNode): boolean { // Field options with labels const FIELD_OPTIONS: { value: ConditionField, label: string }[] = [ { value: 'email', label: 'Email' }, + { value: 'countryCode', label: 'Country Code' }, { value: 'emailDomain', label: 'Email Domain' }, { value: 'authMethod', label: 'Auth Method' }, { value: 'oauthProvider', label: 'OAuth Provider' }, @@ -82,11 +83,18 @@ function isNumericField(field: ConditionField): boolean { return field === 'riskScores.bot' || field === 'riskScores.freeTrialAbuse'; } +function normalizeCountryCodeValue(value: string): string { + return value.trim().toUpperCase(); +} + // Get available operators for a field function getOperatorsForField(field: ConditionField): ConditionOperator[] { if (isNumericField(field)) { return ['equals', 'not_equals', 'greater_than', 'greater_or_equal', 'less_than', 'less_or_equal']; } + if (field === 'countryCode') { + return ['equals', 'not_equals', 'in_list']; + } if (field === 'authMethod' || field === 'oauthProvider') { return ['equals', 'not_equals', 'in_list']; } @@ -113,6 +121,8 @@ function ConditionRow({ }) { const availableOperators = getOperatorsForField(condition.field); const predefinedValues = PREDEFINED_VALUES[condition.field]; + const isCountryCodeField = condition.field === 'countryCode'; + const isCountryCodeListOperator = isCountryCodeField && condition.operator === 'in_list'; // Validate regex when operator is 'matches' const regexError = condition.operator === 'matches' @@ -145,6 +155,21 @@ function ConditionRow({ }; const handleValueChange = (value: string | number | string[]) => { + if (!isCountryCodeField) { + onChange({ ...condition, value }); + return; + } + + if (Array.isArray(value)) { + onChange({ ...condition, value: value.map(normalizeCountryCodeValue) }); + return; + } + + if (typeof value === 'string') { + onChange({ ...condition, value: normalizeCountryCodeValue(value) }); + return; + } + onChange({ ...condition, value }); }; @@ -173,69 +198,84 @@ function ConditionRow({ {/* Value input */} - {condition.operator === 'in_list' ? ( - { - const items = e.target.value.split(',').map(s => s.trim()).filter(Boolean); - handleValueChange(items); - }} - placeholder="value1, value2, ..." - className="h-8 px-2 text-sm bg-background/60 border border-border/50 rounded-md flex-1" - /> - ) : predefinedValues ? ( - - ) : isNumericField(condition.field) ? ( - handleValueChange(e.target.value === '' ? 0 : Number(e.target.value))} - placeholder="0-100" - className="h-8 px-2 text-sm bg-background/60 border border-border/50 rounded-md flex-1" - /> - ) : ( -
+
+ {condition.operator === 'in_list' ? ( { + const items = e.target.value.split(',').map(s => s.trim()).filter(Boolean); + handleValueChange(items); + }} + placeholder={isCountryCodeListOperator ? "US, CA" : "value1, value2, ..."} + className="h-8 px-2 text-sm bg-background/60 border border-border/50 rounded-md w-full" + /> + ) : predefinedValues ? ( + + ) : isNumericField(condition.field) ? ( + handleValueChange(e.target.value === '' ? 0 : Number(e.target.value))} + placeholder="0-100" + className="h-8 px-2 text-sm bg-background/60 border border-border/50 rounded-md w-full" /> - {regexError && ( - - - -
- -
-
- -

{regexError}

-
-
-
- )} -
- )} + ) : ( +
+ handleValueChange(e.target.value)} + placeholder={ + isCountryCodeField + ? "Single code, e.g. US" + : condition.operator === 'matches' + ? "Enter regex pattern..." + : "Enter value..." + } + className={cn( + "h-8 px-2 text-sm bg-background/60 border rounded-md flex-1", + regexError + ? "border-destructive ring-1 ring-destructive/30" + : "border-border/50" + )} + /> + {regexError && ( + + + +
+ +
+
+ +

{regexError}

+
+
+
+ )} +
+ )} + {isCountryCodeField && ( +

+ {isCountryCodeListOperator + ? "Comma-separated ISO country codes, e.g. US, CA" + : "Single ISO country code only, e.g. US"} +

+ )} +
{/* Remove button */} {showRemove && ( diff --git a/apps/dashboard/src/lib/cel-visual-parser.test.ts b/apps/dashboard/src/lib/cel-visual-parser.test.ts index 60d622250a..78591d0587 100644 --- a/apps/dashboard/src/lib/cel-visual-parser.test.ts +++ b/apps/dashboard/src/lib/cel-visual-parser.test.ts @@ -144,6 +144,22 @@ describe('cel-visual-parser', () => { expect(greaterThan).toBe('riskScores.bot > 80'); expect(lessOrEqual).toBe('riskScores.freeTrialAbuse <= 40'); }); + + it('should normalize country code values to uppercase', () => { + expect(visualTreeToCel({ + ...createEmptyCondition(), + field: 'countryCode' as const, + operator: 'equals' as const, + value: 'us', + })).toBe('countryCode == "US"'); + + expect(visualTreeToCel({ + ...createEmptyCondition(), + field: 'countryCode' as const, + operator: 'in_list' as const, + value: ['us', 'ca'], + })).toBe('countryCode in ["US", "CA"]'); + }); }); describe('CEL to visual tree parsing', () => { @@ -194,5 +210,25 @@ describe('cel-visual-parser', () => { expect(result.value).toBe(75); } }); + + it('should parse country code equality condition', () => { + const result = parseCelToVisualTree('countryCode == "US"'); + expect(result).toBeDefined(); + if (result?.type === 'condition') { + expect(result.field).toBe('countryCode'); + expect(result.operator).toBe('equals'); + expect(result.value).toBe('US'); + } + }); + + it('should parse country code in_list condition', () => { + const result = parseCelToVisualTree('countryCode in ["US", "CA"]'); + expect(result).toBeDefined(); + if (result?.type === 'condition') { + expect(result.field).toBe('countryCode'); + expect(result.operator).toBe('in_list'); + expect(result.value).toEqual(['US', 'CA']); + } + }); }); }); diff --git a/apps/dashboard/src/lib/cel-visual-parser.ts b/apps/dashboard/src/lib/cel-visual-parser.ts index 5dc059abbe..b5bc47bbac 100644 --- a/apps/dashboard/src/lib/cel-visual-parser.ts +++ b/apps/dashboard/src/lib/cel-visual-parser.ts @@ -8,6 +8,7 @@ * - email == "value" / email != "value" * - email.endsWith("@domain.com") * - email.matches("regex") + * - countryCode == "US" / countryCode in ["US", "CA"] * - emailDomain == "domain.com" / emailDomain in ["d1", "d2"] * - authMethod == "password" / authMethod in ["password", "otp"] * - oauthProvider == "google" / oauthProvider in ["google", "github"] @@ -29,6 +30,7 @@ export type ConditionOperator = export type ConditionField = | 'email' + | 'countryCode' | 'emailDomain' | 'authMethod' | 'oauthProvider' @@ -86,8 +88,29 @@ function unescapeCelString(value: string): string { return value.replace(/\\\\/g, '\\').replace(/\\"/g, '"'); } +function normalizeCountryCodeValue(value: string): string { + return value.trim().toUpperCase(); +} + +function normalizeConditionValue(condition: ConditionNode): ConditionNode['value'] { + if (condition.field !== 'countryCode') { + return condition.value; + } + + if (Array.isArray(condition.value)) { + return condition.value.map(normalizeCountryCodeValue); + } + + if (typeof condition.value === 'number') { + return condition.value; + } + + return normalizeCountryCodeValue(condition.value); +} + function conditionToCel(condition: ConditionNode): string { - const { field, operator, value } = condition; + const { field, operator } = condition; + const value = normalizeConditionValue(condition); const valueAsNumber = typeof value === 'number' ? value : Number(value); const useNumericValue = isNumericField(field) && Number.isFinite(valueAsNumber); @@ -485,4 +508,3 @@ export function createEmptyGroup(operator: 'and' | 'or' = 'and'): GroupNode { children: [], }; } - diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts index 0c8c1cb5db..c2c4901611 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts @@ -1,7 +1,7 @@ import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; import { describe } from "vitest"; import { it } from "../../../../../helpers"; -import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../backend-helpers"; +import { Auth, InternalApiKey, Project, backendContext, niceBackendFetch } from "../../../../backend-helpers"; describe("sign-up rules", () => { // ========================================== @@ -1272,6 +1272,200 @@ describe("sign-up rules", () => { expect(response.tokenResponse.status).toBe(200); }); + // ========================================== + // COUNTRY CODE CONDITIONS + // ========================================== + + it("should reject password signup when countryCode reject rule matches", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + credential_enabled: true, + }, + }); + + backendContext.set({ + ipData: { + ipAddress: "127.0.0.1", + country: "US", + city: "New York", + region: "NY", + latitude: 40.7128, + longitude: -74.006, + tzIdentifier: "America/New_York", + }, + }); + + await Project.updateConfig({ + 'auth.signUpRules.block-us': { + enabled: true, + displayName: 'Block US signups', + priority: 0, + condition: 'countryCode == "US"', + action: { + type: 'reject', + message: 'US signups are not allowed', + }, + }, + 'auth.signUpRulesDefaultAction': 'allow', + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: `user-${generateSecureRandomString(8)}@example.com`, + password: generateSecureRandomString(), + }, + }); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + code: 'SIGN_UP_REJECTED', + }); + }); + + it("should restrict password signup when countryCode restrict rule matches", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + credential_enabled: true, + }, + }); + + backendContext.set({ + ipData: { + ipAddress: "127.0.0.1", + country: "CA", + city: "Toronto", + region: "ON", + latitude: 43.6532, + longitude: -79.3832, + tzIdentifier: "America/Toronto", + }, + }); + + await Project.updateConfig({ + 'auth.signUpRules.restrict-canada': { + enabled: true, + displayName: 'Restrict Canada signups', + priority: 0, + condition: 'countryCode == "CA"', + action: { + type: 'restrict', + }, + }, + 'auth.signUpRulesDefaultAction': 'allow', + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: `user-${generateSecureRandomString(8)}@example.com`, + password: generateSecureRandomString(), + }, + }); + + expect(response.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${response.body.user_id}`, { + method: "GET", + accessType: "admin", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.restricted_by_admin).toBe(true); + expect(userResponse.body.restricted_by_admin_private_details).toContain("restrict-canada"); + }); + + it("should reject OTP signup when countryCode rule matches the verification flow request", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + magic_link_enabled: true, + }, + }); + + backendContext.set({ + ipData: { + ipAddress: "127.0.0.1", + country: "DE", + city: "Berlin", + region: "BE", + latitude: 52.52, + longitude: 13.405, + tzIdentifier: "Europe/Berlin", + }, + }); + + await Project.updateConfig({ + 'auth.signUpRules.block-germany': { + enabled: true, + displayName: 'Block Germany signups', + priority: 0, + condition: 'countryCode == "DE"', + action: { + type: 'reject', + message: 'Germany signups are not allowed', + }, + }, + 'auth.signUpRulesDefaultAction': 'allow', + }); + + const { sendSignInCodeResponse } = await Auth.Otp.sendSignInCode(); + const response = await niceBackendFetch("/api/v1/auth/otp/sign-in", { + method: "POST", + accessType: "client", + body: { + code: await Auth.Otp.getSignInCodeFromMailbox(sendSignInCodeResponse.body.nonce), + }, + }); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + code: 'SIGN_UP_REJECTED', + }); + }); + + it("should reject OAuth signup when countryCode rule matches the callback request", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + }, + }); + await InternalApiKey.createAndSetProjectKeys(); + + backendContext.set({ + ipData: { + ipAddress: "127.0.0.1", + country: "FR", + city: "Paris", + region: "IDF", + latitude: 48.8566, + longitude: 2.3522, + tzIdentifier: "Europe/Paris", + }, + }); + + await Project.updateConfig({ + 'auth.signUpRules.block-france': { + enabled: true, + displayName: 'Block France signups', + priority: 0, + condition: 'countryCode == "FR"', + action: { + type: 'reject', + message: 'France signups are not allowed', + }, + }, + 'auth.signUpRulesDefaultAction': 'allow', + }); + + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + code: 'SIGN_UP_REJECTED', + }); + }); + // ========================================== // COMPOUND CONDITIONS (AND/OR) // ========================================== diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts index 9329235bda..ab0e403fc6 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts @@ -10,7 +10,13 @@ describe("with admin access", () => { accessType: "admin", body: { email: "user@example.com", + country_code: null, auth_method: "password", + oauth_provider: null, + risk_scores: { + bot: 0, + free_trial_abuse: 0, + }, }, }); @@ -56,8 +62,13 @@ describe("with admin access", () => { accessType: "admin", body: { email: "test@example.com", + country_code: null, auth_method: "oauth", oauth_provider: "google", + risk_scores: { + bot: 0, + free_trial_abuse: 0, + }, }, }); @@ -103,7 +114,9 @@ describe("with admin access", () => { accessType: "admin", body: { email: "risk@example.com", + country_code: null, auth_method: "password", + oauth_provider: null, risk_scores: { bot: 90, free_trial_abuse: 10, @@ -126,4 +139,91 @@ describe("with admin access", () => { }, }); }); + + it("evaluates country code conditions and normalizes country input", async ({ expect }) => { + await Project.createAndSwitch(); + await Project.updateConfig({ + "auth.signUpRules.block-us": { + enabled: true, + displayName: "Block US signups", + priority: 1, + condition: 'countryCode == "US"', + action: { + type: "reject", + message: "US blocked", + }, + }, + "auth.signUpRulesDefaultAction": "allow", + }); + + const response = await niceBackendFetch("/api/v1/internal/sign-up-rules-test", { + method: "POST", + accessType: "admin", + body: { + email: "country@example.com", + country_code: "us", + auth_method: "password", + oauth_provider: null, + risk_scores: { + bot: 0, + free_trial_abuse: 0, + }, + }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + context: { + country_code: "US", + }, + outcome: { + should_allow: false, + decision: "reject", + decision_rule_id: "block-us", + }, + }); + }); + + it("evaluates country code in_list conditions", async ({ expect }) => { + await Project.createAndSwitch(); + await Project.updateConfig({ + "auth.signUpRules.allow-na": { + enabled: true, + displayName: "Allow North America", + priority: 1, + condition: 'countryCode in ["US", "CA"]', + action: { + type: "allow", + }, + }, + "auth.signUpRulesDefaultAction": "reject", + }); + + const response = await niceBackendFetch("/api/v1/internal/sign-up-rules-test", { + method: "POST", + accessType: "admin", + body: { + email: "country@example.com", + country_code: "ca", + auth_method: "password", + oauth_provider: null, + risk_scores: { + bot: 0, + free_trial_abuse: 0, + }, + }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + context: { + country_code: "CA", + }, + outcome: { + should_allow: true, + decision: "allow", + decision_rule_id: "allow-na", + }, + }); + }); }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts index 0def723e07..7c19884499 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts @@ -1,7 +1,7 @@ import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; import { describe } from "vitest"; import { it } from "../../../../helpers"; -import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../backend-helpers"; +import { Auth, InternalApiKey, Project, backendContext, niceBackendFetch } from "../../../backend-helpers"; describe("risk scores", () => { // ========================================== @@ -189,6 +189,159 @@ describe("risk scores", () => { }); }); + describe("signup country persistence", () => { + it("should derive country_code from xx-test@example.com when request geo is unavailable", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: "us-test@example.com", + password: generateSecureRandomString(), + }, + }); + expect(response.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${response.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.country_code).toBe("US"); + }); + + it("should persist country_code for password signup", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + backendContext.set({ + ipData: { + ipAddress: "127.0.0.1", + country: "CA", + city: "Toronto", + region: "ON", + latitude: 43.6532, + longitude: -79.3832, + tzIdentifier: "America/Toronto", + }, + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: `country-${generateSecureRandomString(8)}@example.com`, + password: generateSecureRandomString(), + }, + }); + expect(response.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${response.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.country_code).toBe("CA"); + }); + + it("should persist country_code for OTP signup", async ({ expect }) => { + await Project.createAndSwitch({ + config: { magic_link_enabled: true }, + }); + + backendContext.set({ + ipData: { + ipAddress: "127.0.0.1", + country: "DE", + city: "Berlin", + region: "BE", + latitude: 52.52, + longitude: 13.405, + tzIdentifier: "Europe/Berlin", + }, + }); + + await Auth.Otp.signIn(); + + const meResponse = await niceBackendFetch("/api/v1/users/me", { + accessType: "server", + }); + + expect(meResponse.status).toBe(200); + expect(meResponse.body.country_code).toBe("DE"); + }); + + it("should persist country_code for OAuth signup", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + }, + }); + await InternalApiKey.createAndSetProjectKeys(); + + backendContext.set({ + ipData: { + ipAddress: "127.0.0.1", + country: "FR", + city: "Paris", + region: "IDF", + latitude: 48.8566, + longitude: 2.3522, + tzIdentifier: "Europe/Paris", + }, + }); + + const response = await Auth.OAuth.signIn(); + expect(response.tokenResponse.status).toBe(200); + + const meResponse = await niceBackendFetch("/api/v1/users/me", { + accessType: "server", + }); + + expect(meResponse.status).toBe(200); + expect(meResponse.body.country_code).toBe("FR"); + }); + + it("should keep country_code null for anonymous users", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + backendContext.set({ + ipData: { + ipAddress: "127.0.0.1", + country: "US", + city: "New York", + region: "NY", + latitude: 40.7128, + longitude: -74.006, + tzIdentifier: "America/New_York", + }, + }); + + const anonResponse = await niceBackendFetch("/api/v1/auth/anonymous/sign-up", { + accessType: "client", + method: "POST", + body: {}, + }); + expect(anonResponse.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${anonResponse.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.country_code).toBeNull(); + }); + }); + // ========================================== // ANONYMOUS USERS // ========================================== diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 75adc92716..1b3ec75e43 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -76,3 +76,18 @@ A: The card playground now includes a `Header Actions` toggle that injects a sam Q: How should unsubscribe-link e2e tests avoid breakage from email theme/layout changes? A: In `apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts`, avoid snapshotting the entire rendered HTML for transactional emails; assert stable behavior instead (email content present and `/api/v1/emails/unsubscribe-link` absent) so cosmetic wrapper/style changes do not fail the test. + +Q: What should `getSpoofableEndUserLocation()` return for normal browser/proxy traffic? +A: It should return location fields from `spoofedInfo` when `getEndUserInfo()` reports `maybeSpoofed: true`, and from `exactInfo` otherwise. Returning only exact info drops country data for the normal header-derived browser path and breaks geo-based signup rules. + +Q: How should nullable signup-rule context inputs be typed? +A: If a field already uses `null` to represent absence, keep it non-optional and pass explicit `null` at callsites. In practice, `createSignUpRuleContext` and signup-rule option plumbing should use `countryCode: string | null` and `ipAddress: string | null`, not `?: ... | null`, so `undefined` never leaks into the flow. + +Q: What shape should `/api/v1/internal/sign-up-rules-test` use for optional-looking inputs? +A: Use explicit nullable fields instead of omitting them. `email`, `country_code`, and `oauth_provider` should be sent and validated as `string | null`, with the dashboard tester and e2e tests passing `null` rather than leaving them `undefined`. + +Q: Where is signup country code stored and exposed for dashboard user details? +A: Persist the best-effort signup country on `ProjectUser.countryCode`, expose it as `country_code` on the server user CRUD read shape, map it to `ServerUser.countryCode` in `packages/template`, and render it as a read-only field in the dashboard user details page. + +Q: Is there a deterministic email-based stub for signup country in local/test flows? +A: Yes. In `apps/backend/src/lib/users.tsx`, if request geo does not provide a country, emails matching `xx-test@example.com` map to `countryCode = XX` (for example `us-test@example.com` -> `US`). This is a test stub analogous to the `test@example.com` risk-score stub and does not override real request geo. diff --git a/examples/demo/src/app/page-client.tsx b/examples/demo/src/app/page-client.tsx index 658154fc7f..d80e487521 100644 --- a/examples/demo/src/app/page-client.tsx +++ b/examples/demo/src/app/page-client.tsx @@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation'; export default function PageClient() { - const user = useUser(); + const user = useUser({ includeRestricted: true }); const router = useRouter(); const app = useStackApp(); @@ -35,7 +35,14 @@ export default function PageClient() {
logged in as - {user.displayName ?? user.primaryEmail} +
+ {user.displayName ?? user.primaryEmail} + {user.isRestricted && ( + + Restricted + + )} +
@@ -54,6 +61,10 @@ export default function PageClient() {
{user.primaryEmail}
)} +
+
Restricted:
+
{user.isRestricted ? `Yes${user.restrictedReason ? ` (${user.restrictedReason.type})` : ''}` : 'No'}
+
diff --git a/packages/stack-shared/src/interface/crud/users.ts b/packages/stack-shared/src/interface/crud/users.ts index bd15329668..b84951ac39 100644 --- a/packages/stack-shared/src/interface/crud/users.ts +++ b/packages/stack-shared/src/interface/crud/users.ts @@ -23,6 +23,7 @@ export const usersCrudServerUpdateSchema = fieldSchema.yupObject({ restricted_by_admin: fieldSchema.yupBoolean().optional().meta({ openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }), restricted_by_admin_reason: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }), restricted_by_admin_private_details: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }), + country_code: fieldSchema.yupString().nullable().optional().meta({ openapiField: { hidden: true } }), risk_scores: fieldSchema.yupObject({ sign_up: fieldSchema.yupObject({ bot: fieldSchema.yupNumber().integer().min(0).max(100).defined(), @@ -70,6 +71,7 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({ restricted_by_admin: fieldSchema.yupBoolean().defined().meta({ openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }), restricted_by_admin_reason: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }), restricted_by_admin_private_details: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }), + country_code: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Best-effort ISO country code captured at sign-up time from request geo headers.', exampleValue: "US" } }), risk_scores: fieldSchema.yupObject({ sign_up: fieldSchema.yupObject({ bot: fieldSchema.yupNumber().min(0).max(100).integer().defined(), diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 245f9f13ab..d98175476f 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -562,6 +562,7 @@ export class _StackServerAppImplIncomplete Date: Mon, 9 Mar 2026 13:07:50 -0700 Subject: [PATCH 03/77] Enhance sign-up rules with derived country code and risk score handling. Introduced new functions for calculating derived country codes and risk scores, updated API request handling to support optional risk scores, and improved validation for user input in the dashboard. Updated tests to cover new functionality and ensure proper handling of country codes and risk scores in user creation and updates. --- .../internal/sign-up-rules-test/route.tsx | 32 +++++-- apps/backend/src/lib/users.tsx | 7 +- .../[projectId]/sign-up-rules/page-client.tsx | 87 +++++++++++-------- .../users/[userId]/page-client.tsx | 41 ++++++++- apps/dashboard/src/components/user-dialog.tsx | 60 ++++++++++++- .../v1/internal/sign-up-rules-test.test.ts | 67 +++++++------- .../endpoints/api/v1/risk-scores.test.ts | 25 +++++- apps/e2e/tests/js/app.test.ts | 23 +++++ claude/CLAUDE-KNOWLEDGE.md | 5 +- .../stack-shared/src/interface/crud/users.ts | 8 +- .../template/src/lib/stack-app/users/index.ts | 28 ++++++ 11 files changed, 296 insertions(+), 87 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx index 3e494f43b0..749d6a5b76 100644 --- a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx +++ b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx @@ -1,5 +1,8 @@ import { createSignUpRuleContext } from "@/lib/cel-evaluator"; +import { getSpoofableEndUserIp, getSpoofableEndUserLocation } from "@/lib/end-users"; +import { calculateSignUpRiskScores } from "@/lib/risk-scores"; import { evaluateSignUpRulesWithTrace } from "@/lib/sign-up-rules"; +import { getDerivedSignUpCountryCode } from "@/lib/users"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -19,13 +22,13 @@ export const POST = createSmartRouteHandler({ }), body: yupObject({ email: yupString().nullable().defined(), - country_code: yupString().nullable().defined(), auth_method: yupString().oneOf(AUTH_METHODS).defined(), oauth_provider: yupString().nullable().defined(), + country_code: yupString().nullable().defined(), risk_scores: yupObject({ bot: yupNumber().min(0).max(100).integer().defined(), free_trial_abuse: yupNumber().min(0).max(100).integer().defined(), - }).defined(), + }).optional(), }).defined(), }), response: yupObject({ @@ -64,15 +67,30 @@ export const POST = createSmartRouteHandler({ }).defined(), }), handler: async (req) => { - const context = createSignUpRuleContext({ - email: req.body.email, - countryCode: req.body.country_code, + const [requestIpAddress, requestLocation] = await Promise.all([ + getSpoofableEndUserIp().then((ip) => ip ?? null), + getSpoofableEndUserLocation(), + ]); + const derivedCountryCode = getDerivedSignUpCountryCode(requestLocation?.countryCode ?? null, req.body.email); + const derivedRiskScores = await calculateSignUpRiskScores(req.auth.tenancy, { + primaryEmail: req.body.email, + primaryEmailVerified: req.body.auth_method === "otp", authMethod: req.body.auth_method, oauthProvider: req.body.oauth_provider, - riskScores: { + ipAddress: requestIpAddress, + }); + const riskScores = req.body.risk_scores === undefined + ? derivedRiskScores + : { bot: req.body.risk_scores.bot, freeTrialAbuse: req.body.risk_scores.free_trial_abuse, - }, + }; + const context = createSignUpRuleContext({ + email: req.body.email, + countryCode: req.body.country_code ?? derivedCountryCode, + authMethod: req.body.auth_method, + oauthProvider: req.body.oauth_provider, + riskScores, }); const trace = evaluateSignUpRulesWithTrace(req.auth.tenancy, context); diff --git a/apps/backend/src/lib/users.tsx b/apps/backend/src/lib/users.tsx index 524a2e0f63..b00a6b1f93 100644 --- a/apps/backend/src/lib/users.tsx +++ b/apps/backend/src/lib/users.tsx @@ -27,6 +27,10 @@ function getStubSignUpCountryCode(email: string | null): string | null { return match === null ? null : match[1].toUpperCase(); } +export function getDerivedSignUpCountryCode(requestCountryCode: string | null, email: string | null): string | null { + return requestCountryCode ?? getStubSignUpCountryCode(email); +} + /** * Creates or upgrades an anonymous user with sign-up rule evaluation. * @@ -63,7 +67,7 @@ export async function createOrUpgradeAnonymousUserWithRules( ]); const countryCode = signUpRuleOptions.countryCode !== null ? signUpRuleOptions.countryCode - : (requestLocation?.countryCode ?? getStubSignUpCountryCode(email)); + : getDerivedSignUpCountryCode(requestLocation?.countryCode ?? null, email); const riskScores = await calculateSignUpRiskScores(tenancy, { primaryEmail: email ?? null, @@ -109,7 +113,6 @@ export async function createOrUpgradeAnonymousUserWithRules( }, }; - // Proceed with user creation/upgrade return await createOrUpgradeAnonymousUserWithoutRules( tenancy, currentUser, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx index 21c53817a5..a80e70fea0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -524,29 +524,49 @@ function TestRulesCard({ stackAdminApp: ReturnType, }) { const [email, setEmail] = useState(''); - const [countryCode, setCountryCode] = useState(''); const [authMethod, setAuthMethod] = useState('password'); const [oauthProvider, setOauthProvider] = useState(''); - const [riskScoreBot, setRiskScoreBot] = useState(0); - const [riskScoreFreeTrialAbuse, setRiskScoreFreeTrialAbuse] = useState(0); + const [countryCodeOverride, setCountryCodeOverride] = useState(''); + const [botRiskScoreOverride, setBotRiskScoreOverride] = useState(''); + const [freeTrialAbuseRiskScoreOverride, setFreeTrialAbuseRiskScoreOverride] = useState(''); const [result, setResult] = useState(null); const [runTest, isRunning] = useAsyncCallback(async () => { + const normalizedCountryCodeOverride = countryCodeOverride.trim().toUpperCase(); + const normalizedBotRiskScoreOverride = botRiskScoreOverride.trim(); + const normalizedFreeTrialAbuseRiskScoreOverride = freeTrialAbuseRiskScoreOverride.trim(); + if (normalizedCountryCodeOverride !== '' && !/^[A-Z]{2}$/.test(normalizedCountryCodeOverride)) { + throw new StackAssertionError("Country code override must be a two-letter ISO code."); + } + if (normalizedBotRiskScoreOverride !== '' && !/^(100|[1-9]?[0-9])$/.test(normalizedBotRiskScoreOverride)) { + throw new StackAssertionError("Bot risk score override must be an integer between 0 and 100."); + } + if (normalizedFreeTrialAbuseRiskScoreOverride !== '' && !/^(100|[1-9]?[0-9])$/.test(normalizedFreeTrialAbuseRiskScoreOverride)) { + throw new StackAssertionError("Free trial abuse risk score override must be an integer between 0 and 100."); + } + if ((normalizedBotRiskScoreOverride === '') !== (normalizedFreeTrialAbuseRiskScoreOverride === '')) { + throw new StackAssertionError("Bot risk score and free trial abuse risk score overrides must both be provided or both be left blank."); + } + const response = await (stackAdminApp as any)[stackAppInternalsSymbol].sendRequest( '/internal/sign-up-rules-test', { method: 'POST', body: JSON.stringify({ email: email === '' ? null : email, - country_code: countryCode === '' ? null : countryCode, auth_method: authMethod, oauth_provider: authMethod === 'oauth' ? (oauthProvider === '' ? null : oauthProvider) : null, - risk_scores: { - bot: riskScoreBot, - free_trial_abuse: riskScoreFreeTrialAbuse, - }, + country_code: normalizedCountryCodeOverride === '' ? null : normalizedCountryCodeOverride, + ...(normalizedBotRiskScoreOverride === '' + ? {} + : { + risk_scores: { + bot: Number(normalizedBotRiskScoreOverride), + free_trial_abuse: Number(normalizedFreeTrialAbuseRiskScoreOverride), + }, + }), }), headers: { 'Content-Type': 'application/json', @@ -561,7 +581,7 @@ function TestRulesCard({ const data = await response.json(); setResult(data); - }, [authMethod, countryCode, email, oauthProvider, riskScoreBot, riskScoreFreeTrialAbuse, stackAdminApp]); + }, [authMethod, botRiskScoreOverride, countryCodeOverride, email, freeTrialAbuseRiskScoreOverride, oauthProvider, stackAdminApp]); const handleAuthMethodChange = (value: string) => { if (value === 'password' || value === 'otp' || value === 'oauth' || value === 'passkey') { @@ -632,16 +652,6 @@ function TestRulesCard({ placeholder="user@company.com" /> -
- - Country code - - setCountryCode(e.target.value.toUpperCase())} - placeholder="US" - /> -
Auth method @@ -678,31 +688,38 @@ function TestRulesCard({
-
+
+
+ + Country code override + + setCountryCodeOverride(e.target.value.toUpperCase())} + placeholder="US" + maxLength={2} + /> +
- Risk score: bot + Bot score override setRiskScoreBot(Math.max(0, Math.min(100, Number(e.target.value) || 0)))} + value={botRiskScoreOverride} + onChange={(e) => setBotRiskScoreOverride(e.target.value)} + placeholder="0-100" + inputMode="numeric" />
- Risk score: free trial abuse + Free trial abuse override setRiskScoreFreeTrialAbuse(Math.max(0, Math.min(100, Number(e.target.value) || 0)))} + value={freeTrialAbuseRiskScoreOverride} + onChange={(e) => setFreeTrialAbuseRiskScoreOverride(e.target.value)} + placeholder="0-100" + inputMode="numeric" />
@@ -719,7 +736,7 @@ function TestRulesCard({ Simulate a sign-up request to preview which rules trigger. - Country matching uses best-effort proxy geolocation headers when they are available. + Leave overrides blank to derive country code and risk scores on the server from request geolocation and signup context.
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index c81f99e9b9..609afb701f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -388,6 +388,15 @@ type UserDetailsProps = { function UserDetails({ user }: UserDetailsProps) { const [newPassword, setNewPassword] = useState(null); + + const parseRiskScore = (value: string): number => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + throw new StackAssertionError("Risk scores must be integers between 0 and 100"); + } + return parsed; + }; + return (
} name="User ID"> @@ -418,13 +427,39 @@ function UserDetails({ user }: UserDetailsProps) { } name="Risk score: bot"> - + { + await user.update({ + riskScores: { + signUp: { + bot: parseRiskScore(newValue), + freeTrialAbuse: user.riskScores.signUp.freeTrialAbuse, + }, + }, + }); + }} /> } name="Sign-up country code"> - + { + const normalized = newValue.trim().toUpperCase(); + if (normalized !== '' && !/^[A-Z]{2}$/.test(normalized)) { + throw new StackAssertionError("Country code must be empty or a 2-letter ISO code"); + } + await user.update({ + countryCode: normalized === '' ? null : normalized, + }); + }} /> } name="Risk score: free trial abuse"> - + { + await user.update({ + riskScores: { + signUp: { + bot: user.riskScores.signUp.bot, + freeTrialAbuse: parseRiskScore(newValue), + }, + }, + }); + }} />
diff --git a/apps/dashboard/src/components/user-dialog.tsx b/apps/dashboard/src/components/user-dialog.tsx index 1047e16923..6b2b95561f 100644 --- a/apps/dashboard/src/components/user-dialog.tsx +++ b/apps/dashboard/src/components/user-dialog.tsx @@ -41,6 +41,9 @@ export function UserDialog(props: { } else { defaultValues = { signedUpAt: new Date(), + countryCode: "", + botRiskScore: "", + freeTrialAbuseRiskScore: "", }; } @@ -71,15 +74,52 @@ export function UserDialog(props: { }).optional(), passwordEnabled: yup.boolean().optional(), updatePassword: yup.boolean().optional(), + countryCode: yup.string().test({ + name: "country-code-format", + message: "Country code must be a two-letter ISO code", + test: (value) => value == null || value === "" || /^[A-Z]{2}$/.test(value), + }).optional(), + botRiskScore: yup.string().test({ + name: "bot-risk-score-format", + message: "Bot risk score must be an integer between 0 and 100", + test: (value) => value == null || value === "" || /^(100|[1-9]?[0-9])$/.test(value), + }).optional(), + freeTrialAbuseRiskScore: yup.string().test({ + name: "free-trial-risk-score-format", + message: "Free trial abuse score must be an integer between 0 and 100", + test: (value) => value == null || value === "" || /^(100|[1-9]?[0-9])$/.test(value), + }).optional(), + }).test({ + name: "risk-score-pair", + message: "Bot risk score and free trial abuse score must both be provided or both be empty", + test: (value) => { + const botRiskScore = value.botRiskScore?.trim() ?? ""; + const freeTrialAbuseRiskScore = value.freeTrialAbuseRiskScore?.trim() ?? ""; + return (botRiskScore === "") === (freeTrialAbuseRiskScore === ""); + }, }); async function handleSubmit(values: yup.InferType) { + const normalizedCountryCode = values.countryCode?.trim().toUpperCase() ?? ""; + const normalizedBotRiskScore = values.botRiskScore?.trim() ?? ""; + const normalizedFreeTrialAbuseRiskScore = values.freeTrialAbuseRiskScore?.trim() ?? ""; const userValues = { ...values, primaryEmailAuthEnabled: true, clientMetadata: values.clientMetadata ? JSON.parse(values.clientMetadata) : undefined, clientReadOnlyMetadata: values.clientReadOnlyMetadata ? JSON.parse(values.clientReadOnlyMetadata) : undefined, - serverMetadata: values.serverMetadata ? JSON.parse(values.serverMetadata) : undefined + serverMetadata: values.serverMetadata ? JSON.parse(values.serverMetadata) : undefined, + ...(props.type === "create" ? { + countryCode: normalizedCountryCode === "" ? undefined : normalizedCountryCode, + riskScores: normalizedBotRiskScore === "" && normalizedFreeTrialAbuseRiskScore === "" + ? undefined + : { + signUp: { + bot: Number(normalizedBotRiskScore), + freeTrialAbuse: Number(normalizedFreeTrialAbuseRiskScore), + }, + }, + } : {}), }; try { @@ -148,6 +188,24 @@ export function UserDialog(props: { )} {!form.watch("primaryEmailVerified") && form.watch("otpAuthEnabled") && Primary email must be verified if OTP/magic link sign-in is enabled} + {props.type === "create" && ( + + + Risk and Geo + + +
+ + +
+ + Optional admin-only values for imports or custom anti-abuse systems. Leave blank to use the defaults. + +
+
+
+ )} + Metadata diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts index ab0e403fc6..b058af431c 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts @@ -1,22 +1,19 @@ import { describe } from "vitest"; import { it } from "../../../../../helpers"; -import { Project, niceBackendFetch } from "../../../../backend-helpers"; +import { Project, backendContext, niceBackendFetch } from "../../../../backend-helpers"; describe("with admin access", () => { it("uses default action when no rules match", async ({ expect }) => { await Project.createAndSwitch({ config: {} }); + backendContext.set({ ipData: undefined }); const response = await niceBackendFetch("/api/v1/internal/sign-up-rules-test", { method: "POST", accessType: "admin", body: { email: "user@example.com", - country_code: null, auth_method: "password", oauth_provider: null, - risk_scores: { - bot: 0, - free_trial_abuse: 0, - }, + country_code: null, }, }); @@ -34,6 +31,7 @@ describe("with admin access", () => { it("returns a decision rule when an allow/reject rule matches", async ({ expect }) => { await Project.createAndSwitch(); + backendContext.set({ ipData: undefined }); await Project.updateConfig({ "auth.signUpRules.log-first": { enabled: true, @@ -62,13 +60,9 @@ describe("with admin access", () => { accessType: "admin", body: { email: "test@example.com", - country_code: null, auth_method: "oauth", oauth_provider: "google", - risk_scores: { - bot: 0, - free_trial_abuse: 0, - }, + country_code: null, }, }); @@ -93,8 +87,9 @@ describe("with admin access", () => { }); }); - it("evaluates risk score conditions", async ({ expect }) => { + it("evaluates risk score conditions from admin overrides", async ({ expect }) => { await Project.createAndSwitch(); + backendContext.set({ ipData: undefined }); await Project.updateConfig({ "auth.signUpRules.block-high-bot-score": { enabled: true, @@ -113,13 +108,13 @@ describe("with admin access", () => { method: "POST", accessType: "admin", body: { - email: "risk@example.com", - country_code: null, + email: "user@example.com", auth_method: "password", oauth_provider: null, + country_code: null, risk_scores: { - bot: 90, - free_trial_abuse: 10, + bot: 100, + free_trial_abuse: 100, }, }, }); @@ -128,8 +123,8 @@ describe("with admin access", () => { expect(response.body).toMatchObject({ context: { risk_scores: { - bot: 90, - free_trial_abuse: 10, + bot: 100, + free_trial_abuse: 100, }, }, outcome: { @@ -140,8 +135,19 @@ describe("with admin access", () => { }); }); - it("evaluates country code conditions and normalizes country input", async ({ expect }) => { + it("evaluates country code conditions from admin overrides", async ({ expect }) => { await Project.createAndSwitch(); + backendContext.set({ + ipData: { + ipAddress: "127.0.0.1", + country: "DE", + city: "New York", + region: "NY", + latitude: 40.7128, + longitude: -74.006, + tzIdentifier: "America/New_York", + }, + }); await Project.updateConfig({ "auth.signUpRules.block-us": { enabled: true, @@ -161,13 +167,9 @@ describe("with admin access", () => { accessType: "admin", body: { email: "country@example.com", - country_code: "us", auth_method: "password", oauth_provider: null, - risk_scores: { - bot: 0, - free_trial_abuse: 0, - }, + country_code: "us", }, }); @@ -186,6 +188,17 @@ describe("with admin access", () => { it("evaluates country code in_list conditions", async ({ expect }) => { await Project.createAndSwitch(); + backendContext.set({ + ipData: { + ipAddress: "127.0.0.1", + country: "CA", + city: "Toronto", + region: "ON", + latitude: 43.6532, + longitude: -79.3832, + tzIdentifier: "America/Toronto", + }, + }); await Project.updateConfig({ "auth.signUpRules.allow-na": { enabled: true, @@ -204,13 +217,9 @@ describe("with admin access", () => { accessType: "admin", body: { email: "country@example.com", - country_code: "ca", auth_method: "password", oauth_provider: null, - risk_scores: { - bot: 0, - free_trial_abuse: 0, - }, + country_code: null, }, }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts index 7c19884499..3908a386f6 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts @@ -437,7 +437,7 @@ describe("risk scores", () => { // ========================================== describe("server-side update", () => { - it("should allow server to update risk scores", async ({ expect }) => { + it("should allow risk_scores in server update requests", async ({ expect }) => { await Project.createAndSwitch({ config: { credential_enabled: true }, }); @@ -523,6 +523,25 @@ describe("risk scores", () => { expect(response.status).toBe(400); }); + it("should allow country_code in server update requests", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const response = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "PATCH", + accessType: "server", + body: { + country_code: "FR", + }, + }); + + expect(response.status).toBe(200); + expect(response.body.country_code).toBe("FR"); + }); + it("should reject non-integer risk scores", async ({ expect }) => { await Project.createAndSwitch({ config: { credential_enabled: true }, @@ -695,12 +714,13 @@ describe("risk scores", () => { }); }); - it("should allow setting risk scores when creating users via server API", async ({ expect }) => { + it("should allow risk_scores and country_code when creating users via server API", async ({ expect }) => { const createResponse = await niceBackendFetch("/api/v1/users", { method: "POST", accessType: "server", body: { primary_email: `risky-${generateSecureRandomString(8)}@example.com`, + country_code: "FR", risk_scores: { sign_up: { bot: 55, @@ -711,6 +731,7 @@ describe("risk scores", () => { }); expect(createResponse.status).toBe(201); + expect(createResponse.body.country_code).toBe("FR"); expect(createResponse.body.risk_scores).toEqual({ sign_up: { bot: 55, diff --git a/apps/e2e/tests/js/app.test.ts b/apps/e2e/tests/js/app.test.ts index 048b91d6b1..dbda68e586 100644 --- a/apps/e2e/tests/js/app.test.ts +++ b/apps/e2e/tests/js/app.test.ts @@ -105,6 +105,29 @@ it("should create user on the server", async ({ expect }) => { `); }); +it("should create user on the server with country code and risk scores", async ({ expect }) => { + const { serverApp } = await createApp(); + const user = await serverApp.createUser({ + primaryEmail: "imported-risk@test.com", + primaryEmailAuthEnabled: true, + countryCode: "US", + riskScores: { + signUp: { + bot: 61, + freeTrialAbuse: 27, + }, + }, + }); + + expect(user.countryCode).toBe("US"); + expect(user.riskScores).toEqual({ + signUp: { + bot: 61, + freeTrialAbuse: 27, + }, + }); +}); + it("should throw a helpful error when destructuring user", async ({ expect }) => { const { clientApp, serverApp } = await createApp(); diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 1b3ec75e43..25d5ee2cf1 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -84,10 +84,13 @@ Q: How should nullable signup-rule context inputs be typed? A: If a field already uses `null` to represent absence, keep it non-optional and pass explicit `null` at callsites. In practice, `createSignUpRuleContext` and signup-rule option plumbing should use `countryCode: string | null` and `ipAddress: string | null`, not `?: ... | null`, so `undefined` never leaks into the flow. Q: What shape should `/api/v1/internal/sign-up-rules-test` use for optional-looking inputs? -A: Use explicit nullable fields instead of omitting them. `email`, `country_code`, and `oauth_provider` should be sent and validated as `string | null`, with the dashboard tester and e2e tests passing `null` rather than leaving them `undefined`. +A: `email`, `country_code`, and `oauth_provider` can be explicit `string | null`, but `risk_scores` should never use nullable score values. For admin tester overrides, either omit `risk_scores` entirely to derive scores server-side or provide both numeric fields as concrete integers. Q: Where is signup country code stored and exposed for dashboard user details? A: Persist the best-effort signup country on `ProjectUser.countryCode`, expose it as `country_code` on the server user CRUD read shape, map it to `ServerUser.countryCode` in `packages/template`, and render it as a read-only field in the dashboard user details page. Q: Is there a deterministic email-based stub for signup country in local/test flows? A: Yes. In `apps/backend/src/lib/users.tsx`, if request geo does not provide a country, emails matching `xx-test@example.com` map to `countryCode = XX` (for example `us-test@example.com` -> `US`). This is a test stub analogous to the `test@example.com` risk-score stub and does not override real request geo. + +Q: Who is allowed to set `risk_scores` and `country_code`? +A: Customers/admins can set them through server/admin user create and update surfaces, the server SDK `createUser`/`update`, the dashboard admin create flow, and the internal sign-up-rules tester. End users still cannot set them themselves because `current-user` client update schemas do not expose those fields. diff --git a/packages/stack-shared/src/interface/crud/users.ts b/packages/stack-shared/src/interface/crud/users.ts index b84951ac39..9371769f48 100644 --- a/packages/stack-shared/src/interface/crud/users.ts +++ b/packages/stack-shared/src/interface/crud/users.ts @@ -23,7 +23,7 @@ export const usersCrudServerUpdateSchema = fieldSchema.yupObject({ restricted_by_admin: fieldSchema.yupBoolean().optional().meta({ openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }), restricted_by_admin_reason: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }), restricted_by_admin_private_details: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }), - country_code: fieldSchema.yupString().nullable().optional().meta({ openapiField: { hidden: true } }), + country_code: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Best-effort ISO country code captured at sign-up time from request geo headers.', exampleValue: "US" } }), risk_scores: fieldSchema.yupObject({ sign_up: fieldSchema.yupObject({ bot: fieldSchema.yupNumber().integer().min(0).max(100).defined(), @@ -121,12 +121,6 @@ export const usersCrudServerCreateSchema = usersCrudServerUpdateSchema.omit(['se email: fieldSchema.yupString().nullable().defined().default(null), }).defined()).optional().meta({ openapiField: { hidden: true } }), is_anonymous: fieldSchema.yupBoolean().optional(), - risk_scores: fieldSchema.yupObject({ - sign_up: fieldSchema.yupObject({ - bot: fieldSchema.yupNumber().integer().min(0).max(100).defined(), - free_trial_abuse: fieldSchema.yupNumber().integer().min(0).max(100).defined(), - }).defined(), - }).optional(), }).defined()); export const usersCrudServerDeleteSchema = fieldSchema.yupMixed(); diff --git a/packages/template/src/lib/stack-app/users/index.ts b/packages/template/src/lib/stack-app/users/index.ts index 2b1ba9e7e5..1df10847c2 100644 --- a/packages/template/src/lib/stack-app/users/index.ts +++ b/packages/template/src/lib/stack-app/users/index.ts @@ -446,6 +446,13 @@ export type ServerUserUpdateOptions = { restrictedByAdmin?: boolean, restrictedByAdminReason?: string | null, restrictedByAdminPrivateDetails?: string | null, + countryCode?: string | null, + riskScores?: { + signUp: { + bot: number, + freeTrialAbuse: number, + }, + }, } & UserUpdateOptions; export function serverUserUpdateOptionsToCrud(options: ServerUserUpdateOptions): CurrentUserCrud["Server"]["Update"] { // Base update options @@ -468,6 +475,13 @@ export function serverUserUpdateOptionsToCrud(options: ServerUserUpdateOptions): restricted_by_admin: options.restrictedByAdmin, restricted_by_admin_reason: options.restrictedByAdminReason, restricted_by_admin_private_details: options.restrictedByAdminPrivateDetails, + country_code: options.countryCode, + risk_scores: options.riskScores ? { + sign_up: { + bot: options.riskScores.signUp.bot, + free_trial_abuse: options.riskScores.signUp.freeTrialAbuse, + }, + } : undefined, } as CurrentUserCrud["Server"]["Update"]; } @@ -482,6 +496,13 @@ export type ServerUserCreateOptions = { clientMetadata?: any, clientReadOnlyMetadata?: any, serverMetadata?: any, + countryCode?: string | null, + riskScores?: { + signUp: { + bot: number, + freeTrialAbuse: number, + }, + }, } export function serverUserCreateOptionsToCrud(options: ServerUserCreateOptions): UsersCrud["Server"]["Create"] { return { @@ -494,5 +515,12 @@ export function serverUserCreateOptionsToCrud(options: ServerUserCreateOptions): client_metadata: options.clientMetadata, client_read_only_metadata: options.clientReadOnlyMetadata, server_metadata: options.serverMetadata, + country_code: options.countryCode, + risk_scores: options.riskScores ? { + sign_up: { + bot: options.riskScores.signUp.bot, + free_trial_abuse: options.riskScores.signUp.freeTrialAbuse, + }, + } : undefined, }; } From 26b35067dd2317c493bec49533f42cb533e8ab10 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 9 Mar 2026 14:38:56 -0700 Subject: [PATCH 04/77] Refactor country code handling across the application. Introduced a centralized `countryCodeSchema` for validation and normalization, updated relevant components and APIs to utilize this schema, and replaced ad-hoc country code handling with a new `CountryCodeSelect` component. Enhanced tests to ensure proper validation of country codes in various contexts, including sign-up rules and user dialogs. --- .../internal/sign-up-rules-test/route.tsx | 4 +- apps/backend/src/lib/cel-evaluator.ts | 3 +- apps/backend/src/lib/users.tsx | 14 +- .../[projectId]/sign-up-rules/page-client.tsx | 5 +- .../users/[userId]/page-client.tsx | 24 +-- .../src/components/country-code-select.tsx | 140 +++++++++++++++++ .../rule-builder/condition-builder.test.ts | 32 ++++ .../rule-builder/condition-builder.tsx | 146 +++++++++++++++--- apps/dashboard/src/components/user-dialog.tsx | 13 +- apps/dashboard/src/lib/cel-visual-parser.ts | 10 +- claude/CLAUDE-KNOWLEDGE.md | 9 ++ .../stack-shared/src/interface/crud/users.ts | 4 +- .../src/interface/server-interface.ts | 4 +- packages/stack-shared/src/schema-fields.ts | 12 ++ .../stack-shared/src/utils/country-codes.ts | 47 ++++++ packages/stack-shared/src/utils/unicode.tsx | 6 +- 16 files changed, 414 insertions(+), 59 deletions(-) create mode 100644 apps/dashboard/src/components/country-code-select.tsx create mode 100644 apps/dashboard/src/components/rule-builder/condition-builder.test.ts create mode 100644 packages/stack-shared/src/utils/country-codes.ts diff --git a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx index 749d6a5b76..a4239ede7a 100644 --- a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx +++ b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx @@ -4,7 +4,7 @@ import { calculateSignUpRiskScores } from "@/lib/risk-scores"; import { evaluateSignUpRulesWithTrace } from "@/lib/sign-up-rules"; import { getDerivedSignUpCountryCode } from "@/lib/users"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, adminAuthTypeSchema, countryCodeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; const AUTH_METHODS = ['password', 'otp', 'oauth', 'passkey'] as const; const ACTION_TYPES = ['allow', 'reject', 'restrict', 'log'] as const; @@ -24,7 +24,7 @@ export const POST = createSmartRouteHandler({ email: yupString().nullable().defined(), auth_method: yupString().oneOf(AUTH_METHODS).defined(), oauth_provider: yupString().nullable().defined(), - country_code: yupString().nullable().defined(), + country_code: countryCodeSchema.nullable().defined(), risk_scores: yupObject({ bot: yupNumber().min(0).max(100).integer().defined(), free_trial_abuse: yupNumber().min(0).max(100).integer().defined(), diff --git a/apps/backend/src/lib/cel-evaluator.ts b/apps/backend/src/lib/cel-evaluator.ts index e08351166e..2c4da6725b 100644 --- a/apps/backend/src/lib/cel-evaluator.ts +++ b/apps/backend/src/lib/cel-evaluator.ts @@ -1,4 +1,5 @@ import { evaluate } from "cel-js"; +import { normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields"; import { normalizeEmail } from "./emails"; import { SignUpRiskScores } from "./risk-scores"; @@ -179,7 +180,7 @@ export function createSignUpRuleContext(params: { emailDomain = email.includes('@') ? (email.split('@').pop() ?? '') : ''; } - const countryCode = params.countryCode === null ? '' : params.countryCode.trim().toUpperCase(); + const countryCode = params.countryCode === null ? '' : normalizeCountryCode(params.countryCode); return { email, diff --git a/apps/backend/src/lib/users.tsx b/apps/backend/src/lib/users.tsx index b00a6b1f93..0e3ceb586a 100644 --- a/apps/backend/src/lib/users.tsx +++ b/apps/backend/src/lib/users.tsx @@ -1,6 +1,7 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud"; import { KnownErrors } from "@stackframe/stack-shared"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { isValidCountryCode, normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields"; import { KeyIntersect } from "@stackframe/stack-shared/dist/utils/types"; import { createSignUpRuleContext } from "./cel-evaluator"; import { getSpoofableEndUserIp, getSpoofableEndUserLocation } from "./end-users"; @@ -28,8 +29,19 @@ function getStubSignUpCountryCode(email: string | null): string | null { } export function getDerivedSignUpCountryCode(requestCountryCode: string | null, email: string | null): string | null { - return requestCountryCode ?? getStubSignUpCountryCode(email); + if (requestCountryCode !== null) { + const normalizedCountryCode = normalizeCountryCode(requestCountryCode); + if (isValidCountryCode(normalizedCountryCode)) { + return normalizedCountryCode; + } + } + return getStubSignUpCountryCode(email); } +import.meta.vitest?.test("getDerivedSignUpCountryCode", ({ expect }) => { + expect(getDerivedSignUpCountryCode(" us ", null)).toBe("US"); + expect(getDerivedSignUpCountryCode("usa", "ca-test@example.com")).toBe("CA"); + expect(getDerivedSignUpCountryCode("1", null)).toBeNull(); +}); /** * Creates or upgrades an anonymous user with sign-up rule evaluation. diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx index a80e70fea0..8ce1ab3b80 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -38,6 +38,7 @@ import { ArrowsDownUpIcon, CheckIcon, PencilSimpleIcon, PlusIcon, TrashIcon, XIc import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback"; import type { SignUpRule, SignUpRuleAction } from "@stackframe/stack-shared/dist/interface/crud/sign-up-rules"; +import { isValidCountryCode, normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { standardProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; @@ -532,10 +533,10 @@ function TestRulesCard({ const [result, setResult] = useState(null); const [runTest, isRunning] = useAsyncCallback(async () => { - const normalizedCountryCodeOverride = countryCodeOverride.trim().toUpperCase(); + const normalizedCountryCodeOverride = normalizeCountryCode(countryCodeOverride); const normalizedBotRiskScoreOverride = botRiskScoreOverride.trim(); const normalizedFreeTrialAbuseRiskScoreOverride = freeTrialAbuseRiskScoreOverride.trim(); - if (normalizedCountryCodeOverride !== '' && !/^[A-Z]{2}$/.test(normalizedCountryCodeOverride)) { + if (normalizedCountryCodeOverride !== '' && !isValidCountryCode(normalizedCountryCodeOverride)) { throw new StackAssertionError("Country code override must be a two-letter ISO code."); } if (normalizedBotRiskScoreOverride !== '' && !/^(100|[1-9]?[0-9])$/.test(normalizedBotRiskScoreOverride)) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index 609afb701f..4773b555ba 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -45,8 +45,11 @@ import { DeleteUserDialog, ImpersonateUserDialog } from "@/components/user-dialo import { AtIcon, CalendarIcon, CheckIcon, DotsThreeIcon, EnvelopeIcon, GlobeIcon, HashIcon, ProhibitIcon, ShieldIcon, SquareIcon, XIcon } from "@phosphor-icons/react"; import { ServerContactChannel, ServerOAuthProvider, ServerUser } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared"; +import { normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields"; +import { CountryCodeSelect } from "@/components/country-code-select"; import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; import { captureError, StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { useState } from "react"; import * as yup from "yup"; @@ -439,15 +442,18 @@ function UserDetails({ user }: UserDetailsProps) { }} /> } name="Sign-up country code"> - { - const normalized = newValue.trim().toUpperCase(); - if (normalized !== '' && !/^[A-Z]{2}$/.test(normalized)) { - throw new StackAssertionError("Country code must be empty or a 2-letter ISO code"); - } - await user.update({ - countryCode: normalized === '' ? null : normalized, - }); - }} /> + { + runAsynchronouslyWithAlert(async () => { + await user.update({ + countryCode: newValue ? normalizeCountryCode(newValue) : null, + }); + }); + }} + placeholder="-" + className="w-full h-8 text-sm" + /> } name="Risk score: free trial abuse"> { diff --git a/apps/dashboard/src/components/country-code-select.tsx b/apps/dashboard/src/components/country-code-select.tsx new file mode 100644 index 0000000000..35cd5e3500 --- /dev/null +++ b/apps/dashboard/src/components/country-code-select.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + FormControl, + FormField, + FormItem, + FormMessage, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui"; +import { FieldLabel } from "@/components/form-fields"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { ISO_3166_ALPHA_2_COUNTRY_CODES } from "@stackframe/stack-shared/dist/schema-fields"; +import { Control, FieldValues, Path } from "react-hook-form"; +import { useState } from "react"; + +const COUNTRY_CODE_OPTIONS = ISO_3166_ALPHA_2_COUNTRY_CODES.map((code) => ({ + value: code, + label: code, +})); + +type CountryCodeSelectProps = { + value: string | null, + onChange: (value: string | null) => void, + placeholder?: string, + disabled?: boolean, + className?: string, + allowClear?: boolean, +}; + +export function CountryCodeSelect({ + value, + onChange, + placeholder = "Select country code...", + disabled, + className, + allowClear = true, +}: CountryCodeSelectProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + No country found. + + {allowClear && value && ( + { + onChange(null); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear + + )} + {COUNTRY_CODE_OPTIONS.map((option) => ( + { + onChange(option.value); + setOpen(false); + }} + > + {option.label} + {value === option.value && ( + + )} + + ))} + + + + + + ); +} + +export function CountryCodeField(props: { + control: Control, + name: Path, + label?: React.ReactNode, + placeholder?: string, + required?: boolean, + disabled?: boolean, +}) { + return ( + ( + + + + )} + /> + ); +} diff --git a/apps/dashboard/src/components/rule-builder/condition-builder.test.ts b/apps/dashboard/src/components/rule-builder/condition-builder.test.ts new file mode 100644 index 0000000000..676826e70e --- /dev/null +++ b/apps/dashboard/src/components/rule-builder/condition-builder.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { createEmptyCondition } from "@/lib/cel-visual-parser"; +import { isConditionTreeValid } from "./condition-builder"; + +describe("isConditionTreeValid", () => { + it("accepts valid country codes", () => { + expect(isConditionTreeValid({ + ...createEmptyCondition(), + field: "countryCode", + operator: "equals", + value: "us", + })).toBe(true); + }); + + it("rejects invalid single country codes", () => { + expect(isConditionTreeValid({ + ...createEmptyCondition(), + field: "countryCode", + operator: "equals", + value: "usa", + })).toBe(false); + }); + + it("rejects invalid country codes in lists", () => { + expect(isConditionTreeValid({ + ...createEmptyCondition(), + field: "countryCode", + operator: "in_list", + value: ["US", "USA"], + })).toBe(false); + }); +}); diff --git a/apps/dashboard/src/components/rule-builder/condition-builder.tsx b/apps/dashboard/src/components/rule-builder/condition-builder.tsx index cf29819e89..df330884fe 100644 --- a/apps/dashboard/src/components/rule-builder/condition-builder.tsx +++ b/apps/dashboard/src/components/rule-builder/condition-builder.tsx @@ -10,7 +10,9 @@ import { type GroupNode, type RuleNode, } from "@/lib/cel-visual-parser"; -import { PlusIcon, TrashIcon, WarningCircleIcon } from "@phosphor-icons/react"; +import { MinusIcon, PlusIcon, TrashIcon, WarningCircleIcon } from "@phosphor-icons/react"; +import { CountryCodeSelect } from "@/components/country-code-select"; +import { isValidCountryCode, normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields"; import { standardProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import React from "react"; @@ -33,12 +35,29 @@ function validateRegex(pattern: string): string | null { } } +function validateCountryCodeValue(value: string | string[]): string | null { + const values = Array.isArray(value) ? value : [value]; + return values.every((item) => isValidCountryCode(item)) + ? null + : "Country code must be a valid ISO 3166-1 alpha-2 code"; +} + /** * Recursively checks if a RuleNode tree has any validation errors. * Returns true if the tree is valid, false if there are errors. */ export function isConditionTreeValid(node: RuleNode): boolean { if (node.type === 'condition') { + if (node.field === 'countryCode') { + const countryCodeError = validateCountryCodeValue( + Array.isArray(node.value) + ? node.value + : String(node.value), + ); + if (countryCodeError !== null) { + return false; + } + } // Check regex validation if (node.operator === 'matches') { const error = validateRegex(String(node.value)); @@ -83,10 +102,6 @@ function isNumericField(field: ConditionField): boolean { return field === 'riskScores.bot' || field === 'riskScores.freeTrialAbuse'; } -function normalizeCountryCodeValue(value: string): string { - return value.trim().toUpperCase(); -} - // Get available operators for a field function getOperatorsForField(field: ConditionField): ConditionOperator[] { if (isNumericField(field)) { @@ -123,6 +138,20 @@ function ConditionRow({ const predefinedValues = PREDEFINED_VALUES[condition.field]; const isCountryCodeField = condition.field === 'countryCode'; const isCountryCodeListOperator = isCountryCodeField && condition.operator === 'in_list'; + const countryCodeListValues = isCountryCodeListOperator + ? Array.isArray(condition.value) + ? condition.value + : (() => { + throw new Error("Expected countryCode in_list condition value to be a string array"); + })() + : null; + const countryCodeError = isCountryCodeField + ? validateCountryCodeValue( + Array.isArray(condition.value) + ? condition.value + : String(condition.value), + ) + : null; // Validate regex when operator is 'matches' const regexError = condition.operator === 'matches' @@ -161,18 +190,32 @@ function ConditionRow({ } if (Array.isArray(value)) { - onChange({ ...condition, value: value.map(normalizeCountryCodeValue) }); + onChange({ ...condition, value: value.map(normalizeCountryCode) }); return; } if (typeof value === 'string') { - onChange({ ...condition, value: normalizeCountryCodeValue(value) }); + onChange({ ...condition, value: normalizeCountryCode(value) }); return; } onChange({ ...condition, value }); }; + const handleCountryCodeListItemChange = (index: number, value: string) => { + const values = countryCodeListValues ?? []; + handleValueChange(values.map((item, itemIndex) => itemIndex === index ? value : item)); + }; + + const handleAddCountryCodeListItem = () => { + handleValueChange([...(countryCodeListValues ?? []), '']); + }; + + const handleRemoveCountryCodeListItem = (index: number) => { + const values = countryCodeListValues ?? []; + handleValueChange(values.filter((_, itemIndex) => itemIndex !== index)); + }; + return (
{/* Field selector */} @@ -199,7 +242,44 @@ function ConditionRow({ {/* Value input */}
- {condition.operator === 'in_list' ? ( + {isCountryCodeListOperator ? ( +
+ {countryCodeListValues.map((countryCode, index) => ( +
+ handleCountryCodeListItemChange(index, val ?? "")} + allowClear={false} + className={cn( + "h-8 text-sm flex-1", + countryCodeError !== null && "border-destructive ring-1 ring-destructive/30", + )} + /> + +
+ ))} + +
+ ) : condition.operator === 'in_list' ? ( s.trim()).filter(Boolean); handleValueChange(items); }} - placeholder={isCountryCodeListOperator ? "US, CA" : "value1, value2, ..."} - className="h-8 px-2 text-sm bg-background/60 border border-border/50 rounded-md w-full" + placeholder="value1, value2, ..." + className={cn( + "h-8 px-2 text-sm bg-background/60 border rounded-md w-full", + countryCodeError !== null + ? "border-destructive ring-1 ring-destructive/30" + : "border-border/50", + )} + /> + ) : isCountryCodeField ? ( + handleValueChange(val ?? "")} + allowClear={false} + className={cn( + "h-8 text-sm w-full", + countryCodeError !== null && "border-destructive ring-1 ring-destructive/30", + )} /> ) : predefinedValues ? ( setCountryCodeOverride(e.target.value.toUpperCase())} - placeholder="US" - maxLength={2} + setCountryCodeOverride(val ?? "")} />
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-comprehensive.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-comprehensive.test.ts index 07c42b01bf..59faa19a5c 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-comprehensive.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-comprehensive.test.ts @@ -189,6 +189,7 @@ it("list users includes anonymous users when requested", async ({ expect }) => { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": true, "id": "", @@ -207,6 +208,12 @@ it("list users includes anonymous users when requested", async ({ expect }) => { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts index b1a92f60d4..9bf73a758b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts @@ -442,7 +442,8 @@ describe.sequential('External DB Sync - Advanced Tests', () => { insert_users AS ( INSERT INTO "ProjectUser" ("tenancyId", "projectUserId", "mirroredProjectId", "mirroredBranchId", - "displayName", "createdAt", "updatedAt", "isAnonymous") + "displayName", "createdAt", "updatedAt", "isAnonymous", + "signUpRiskScoreBot", "signUpRiskScoreFreeTrialAbuse") SELECT tenancy_id, project_user_id, @@ -451,7 +452,9 @@ describe.sequential('External DB Sync - Advanced Tests', () => { 'HV User ' || idx, ts, ts, - false + false, + 0, + 0 FROM generated RETURNING "tenancyId", "projectUserId" ) @@ -1020,7 +1023,8 @@ $$;`); insert_users AS ( INSERT INTO "ProjectUser" ("tenancyId", "projectUserId", "mirroredProjectId", "mirroredBranchId", - "displayName", "createdAt", "updatedAt", "isAnonymous") + "displayName", "createdAt", "updatedAt", "isAnonymous", + "signUpRiskScoreBot", "signUpRiskScoreFreeTrialAbuse") SELECT tenancy_id, project_user_id, @@ -1029,7 +1033,9 @@ $$;`); 'Interleave User ' || idx, ts, ts, - false + false, + 0, + 0 FROM generated RETURNING "projectUserId" ), @@ -1100,7 +1106,8 @@ $$;`); insert_users AS ( INSERT INTO "ProjectUser" ("tenancyId", "projectUserId", "mirroredProjectId", "mirroredBranchId", - "displayName", "createdAt", "updatedAt", "isAnonymous") + "displayName", "createdAt", "updatedAt", "isAnonymous", + "signUpRiskScoreBot", "signUpRiskScoreFreeTrialAbuse") SELECT tenancy_id, project_user_id, @@ -1109,7 +1116,9 @@ $$;`); 'Replacement ' || idx, ts, ts, - false + false, + 0, + 0 FROM generated RETURNING "projectUserId" ), diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-high-volume.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-high-volume.test.ts index d30a7e07f7..d52d3663b2 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-high-volume.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-high-volume.test.ts @@ -105,7 +105,8 @@ describe.sequential('External DB Sync - High Volume Tests', () => { insert_users AS ( INSERT INTO "ProjectUser" ("tenancyId", "projectUserId", "mirroredProjectId", "mirroredBranchId", - "displayName", "createdAt", "updatedAt", "isAnonymous") + "displayName", "createdAt", "updatedAt", "isAnonymous", + "signUpRiskScoreBot", "signUpRiskScoreFreeTrialAbuse") SELECT tenancy_id, project_user_id, @@ -114,7 +115,9 @@ describe.sequential('External DB Sync - High Volume Tests', () => { 'HV User ' || idx, ts, ts, - false + false, + 0, + 0 FROM generated RETURNING "tenancyId", "projectUserId" ) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts index bc9cc4306e..a5670bd467 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts @@ -194,7 +194,8 @@ describe.sequential('External DB Sync - Race Condition Tests', () => { insert_users AS ( INSERT INTO "ProjectUser" ("tenancyId", "projectUserId", "mirroredProjectId", "mirroredBranchId", - "displayName", "createdAt", "updatedAt", "isAnonymous") + "displayName", "createdAt", "updatedAt", "isAnonymous", + "signUpRiskScoreBot", "signUpRiskScoreFreeTrialAbuse") SELECT tenancy_id, project_user_id, @@ -203,7 +204,9 @@ describe.sequential('External DB Sync - Race Condition Tests', () => { 'Paged User ' || idx, ts, ts, - false + false, + 0, + 0 FROM generated RETURNING "projectUserId" ), diff --git a/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts index 5533b4290b..b5af11c024 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts @@ -69,6 +69,7 @@ it("creates a team and allows managing users on the server", async ({ expect }) "auth_with_email": false, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -87,6 +88,12 @@ it("creates a team and allows managing users on the server", async ({ expect }) "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": { "client_metadata": null, "client_read_only_metadata": null, @@ -104,6 +111,7 @@ it("creates a team and allows managing users on the server", async ({ expect }) "auth_with_email": false, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -122,6 +130,12 @@ it("creates a team and allows managing users on the server", async ({ expect }) "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": { "client_metadata": null, "client_read_only_metadata": null, @@ -170,6 +184,7 @@ it("creates a team and allows managing users on the server", async ({ expect }) "auth_with_email": false, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -188,6 +203,12 @@ it("creates a team and allows managing users on the server", async ({ expect }) "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": { "client_metadata": null, "client_read_only_metadata": null, @@ -240,6 +261,7 @@ it("lets users be on multiple teams", async ({ expect }) => { "auth_with_email": false, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -258,6 +280,12 @@ it("lets users be on multiple teams", async ({ expect }) => { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": { "client_metadata": null, "client_read_only_metadata": null, @@ -275,6 +303,7 @@ it("lets users be on multiple teams", async ({ expect }) => { "auth_with_email": false, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -293,6 +322,12 @@ it("lets users be on multiple teams", async ({ expect }) => { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": { "client_metadata": null, "client_read_only_metadata": null, @@ -327,6 +362,7 @@ it("lets users be on multiple teams", async ({ expect }) => { "auth_with_email": false, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -345,6 +381,12 @@ it("lets users be on multiple teams", async ({ expect }) => { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": { "client_metadata": null, "client_read_only_metadata": null, @@ -362,6 +404,7 @@ it("lets users be on multiple teams", async ({ expect }) => { "auth_with_email": false, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -380,6 +423,12 @@ it("lets users be on multiple teams", async ({ expect }) => { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": { "client_metadata": null, "client_read_only_metadata": null, @@ -521,18 +570,13 @@ it("removes user from team on the client", async ({ expect }) => { }); expect(response1).toMatchInlineSnapshot(` NiceResponse { - "status": 401, + "status": 400, "body": { - "code": "TEAM_PERMISSION_REQUIRED", - "details": { - "permission_id": "$remove_members", - "team_id": "", - "user_id": "", - }, - "error": "User does not have permission $remove_members in team .", + "code": "CANNOT_GET_OWN_USER_WITHOUT_USER", + "error": "You have specified 'me' as a userId, but did not provide authentication for a user.", }, "headers": Headers { - "x-stack-known-error": "TEAM_PERMISSION_REQUIRED", + "x-stack-known-error": "CANNOT_GET_OWN_USER_WITHOUT_USER",
+ + {emailConfig.provider === "managed" && ( +
+ + Managed Domain + + {emailConfig.managedSubdomain} +
+ )} + + {emailConfig.provider === "managed" && ( +
+ + Sender Local Part + + {emailConfig.managedSenderLocalPart} +
+ )}
); } +const managedEmailSetupSchema = yup.object({ + subdomain: yup + .string() + .trim() + .defined("Managed subdomain is required") + .test( + "non-empty-subdomain", + "Managed subdomain is required", + (value) => value.trim().length > 0, + ) + .matches( + /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9-]{2,63}$/, + "Enter a full subdomain like emails.example.com", + ), + senderLocalPart: yup + .string() + .trim() + .defined("Sender local part is required") + .test( + "non-empty-sender-local-part", + "Sender local part is required", + (value) => value.trim().length > 0, + ), +}); + +function ManagedEmailSetupDialog(props: { + trigger: React.ReactNode, +}) { + const stackAdminApp = useAdminApp(); + const [open, setOpen] = useState(false); + const [setupState, setSetupState] = useState<{ + domainId: string, + nameServerRecords: string[], + subdomain: string, + senderLocalPart: string, + status: "pending_dns" | "pending_verification" | "verified" | "applied" | "failed", + } | null>(null); + const [domains, setDomains] = useState>([]); + const [error, setError] = useState(null); + const [loadingDomains, setLoadingDomains] = useState(false); + + const refreshDomains = async () => { + setLoadingDomains(true); + try { + const result = await stackAdminApp.listManagedEmailDomains(); + setDomains(result); + } finally { + setLoadingDomains(false); + } + }; + + return ( + { + setOpen(newOpen); + if (newOpen) { + runAsynchronouslyWithAlert(async () => { + await refreshDomains(); + }, { + onError: (err) => { + setError(err instanceof Error ? err.message : "Failed to load managed domains"); + }, + }); + } else { + setSetupState(null); + setDomains([]); + setError(null); + } + }} + title="Managed Email Setup" + formSchema={managedEmailSetupSchema} + defaultValues={{ subdomain: "", senderLocalPart: "updates" }} + okButton={setupState ? false : { label: "Start Setup" }} + cancelButton + onSubmit={async (values) => { + const setupResult = await stackAdminApp.setupManagedEmailProvider({ + subdomain: values.subdomain, + senderLocalPart: values.senderLocalPart, + }); + setSetupState({ + domainId: setupResult.domainId, + nameServerRecords: setupResult.nameServerRecords, + subdomain: setupResult.subdomain, + senderLocalPart: setupResult.senderLocalPart, + status: setupResult.status, + }); + await refreshDomains(); + setError(null); + return "prevent-close" as const; + }} + render={(form) => ( + <> + {!setupState && ( + <> + + + + )} + {setupState && ( + + Delegate your subdomain with these NS records + + Add these nameservers at your DNS provider for the managed subdomain you entered. +
+ {setupState.nameServerRecords.map((record) => ( +
{record}
+ ))} +
+
+
+ )} + {setupState && ( +
+ + +
+ )} + {(() => { + const visibleDomains = setupState ? domains.filter((d) => d.domainId === setupState.domainId) : domains; + return
+ Tracked managed domains + {loadingDomains ? ( + Loading managed domains... + ) : visibleDomains.length === 0 ? ( + No managed domains tracked yet. + ) : ( + visibleDomains.map((domain) => ( + + {domain.senderLocalPart}@{domain.subdomain} + + Status: {domain.status} + + + + )) + )} +
; + })()} + {error && {error}} + + )} + /> + ); +} + function EmailLogCard() { const stackAdminApp = useAdminApp(); const [emailLogs, setEmailLogs] = useState([]); @@ -392,6 +627,16 @@ const getDefaultValues = (emailConfig: CompleteConfig['emails']['server'] | unde senderName: emailConfig.senderName, password: emailConfig.password, } as const; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (emailConfig.provider === 'managed') { + return { + type: 'managed', + senderEmail: emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart + ? `${emailConfig.managedSenderLocalPart}@${emailConfig.managedSubdomain}` + : emailConfig.senderEmail, + senderName: emailConfig.senderName ?? project.displayName, + password: emailConfig.password, + } as const; } else { return { type: 'standard', @@ -406,7 +651,7 @@ const getDefaultValues = (emailConfig: CompleteConfig['emails']['server'] | unde }; const emailServerSchema = yup.object({ - type: yup.string().oneOf(['shared', 'standard', 'resend']).defined(), + type: yup.string().oneOf(['shared', 'standard', 'resend', 'managed']).defined(), host: definedWhenTypeIsOneOf(yup.string(), ["standard"], "Host is required"), port: definedWhenTypeIsOneOf(yup.number().min(0, "Port must be a number between 0 and 65535").max(65535, "Port must be a number between 0 and 65535"), ["standard"], "Port is required"), username: definedWhenTypeIsOneOf(yup.string(), ["standard"], "Username is required"), @@ -468,7 +713,7 @@ function InputFieldWithInfo({ name={name} control={control} type={type} - // Don't pass required prop - it adds asterisk which we don't want + // Don't pass required prop - it adds asterisk which we don't want /> ); } @@ -508,6 +753,8 @@ function EditEmailServerDialog(props: { senderEmail: emailConfig.senderEmail, senderName: emailConfig.senderName, provider: emailConfig.type === 'resend' ? 'resend' : 'smtp', + managedSubdomain: undefined, + managedSenderLocalPart: undefined, } satisfies CompleteConfig['emails']['server'] }, pushable: false, @@ -540,6 +787,13 @@ function EditEmailServerDialog(props: { }, pushable: false, }); + } else if (values.type === 'managed') { + // Managed config is set through the ManagedEmailSetupDialog; just close + toast({ + title: "Email server unchanged", + description: "Managed email configuration is controlled through the managed domain setup.", + variant: 'success', + }); } else if (values.type === 'resend') { if (!values.password || !values.senderEmail || !values.senderName) { throwErr("Missing email server config for Resend"); @@ -584,6 +838,7 @@ function EditEmailServerDialog(props: { control={form.control} options={[ { label: "Shared (noreply@stackframe.co)", value: 'shared' }, + { label: "Managed (via managed domain setup)", value: 'managed' }, { label: "Resend (your own email address)", value: 'resend' }, { label: "Custom SMTP server (your own email address)", value: 'standard' }, ]} @@ -618,6 +873,21 @@ function EditEmailServerDialog(props: { } + {form.watch('type') === 'managed' && <> + + Managed Email Domain + + + This email server was configured through the managed domain setup flow. To change the domain or sender, use the managed email setup dialog above. + + {defaultValues.type === 'managed' && defaultValues.senderEmail && ( + + Sender: {defaultValues.senderName ? `${defaultValues.senderName} <${defaultValues.senderEmail}>` : defaultValues.senderEmail} + + )} + + + } {form.watch('type') === 'standard' && <> >( const formId = `${useId()}-form`; const [submitting, setSubmitting] = useState(false); const [openState, setOpenState] = useState(false); + const okButton = props.okButton === false ? false : { + onClick: async () => "prevent-close" as const, + ...(typeof props.okButton === "boolean" ? {} : props.okButton), + props: { + form: formId, + type: "submit" as const, + loading: submitting, + ...((typeof props.okButton === "boolean") ? {} : props.okButton?.props), + }, + }; const handleSubmit = async (values: yup.InferType) => { const res = await props.onSubmit(values); if (res !== 'prevent-close') { @@ -34,16 +44,7 @@ export function SmartFormDialog>( setOpenState(open); props.onOpenChange?.(open); }} - okButton={{ - onClick: async () => "prevent-close", - ...(typeof props.okButton === "boolean" ? {} : props.okButton), - props: { - form: formId, - type: "submit", - loading: submitting, - ...((typeof props.okButton === "boolean") ? {} : props.okButton?.props) - }, - }} + okButton={okButton} > @@ -67,6 +68,16 @@ export function FormDialog( }); const [openState, setOpenState] = useState(false); const [submitting, setSubmitting] = useState(false); + const okButton = props.okButton === false ? false : { + onClick: async () => "prevent-close" as const, + ...(typeof props.okButton == "boolean" ? {} : props.okButton), + props: { + form: formId, + type: "submit" as const, + loading: submitting, + ...((typeof props.okButton == "boolean") ? {} : props.okButton?.props), + }, + }; const onSubmit = async (values: F, e?: React.BaseSyntheticEvent) => { e?.preventDefault(); @@ -122,16 +133,7 @@ export function FormDialog( setOpenState(false); runAsynchronouslyWithAlert(props.onClose?.()); }} - okButton={{ - onClick: async () => "prevent-close", - ...(typeof props.okButton == "boolean" ? {} : props.okButton), - props: { - form: formId, - type: "submit", - loading: submitting, - ...((typeof props.okButton == "boolean") ? {} : props.okButton?.props) - }, - }} + okButton={okButton} >
{ + it("rejects client access for setup endpoint", async ({ expect }) => { + await Project.createAndSwitch(); + + const response = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/setup", { + method: "POST", + accessType: "client", + body: { + subdomain: "mail.example.com", + sender_local_part: "noreply", + }, + }); + + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 401, + "body": { + "code": "INSUFFICIENT_ACCESS_TYPE", + "details": { + "actual_access_type": "client", + "allowed_access_types": ["admin"], + }, + "error": "The x-stack-access-type header must be 'admin', but was 'client'.", + }, + "headers": Headers { + "x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE", +