Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
14f5b22
COUNTRY CODE (1)
mantrakp04 Mar 9, 2026
fb315a9
Enhance sign-up rules with derived country code and risk score handli…
mantrakp04 Mar 9, 2026
26b3506
Refactor country code handling across the application. Introduced a c…
mantrakp04 Mar 9, 2026
7e258d7
Refactor sign-up country code handling to derive only from request ge…
mantrakp04 Mar 9, 2026
5250595
Refactor country code validation by introducing a centralized `validC…
mantrakp04 Mar 9, 2026
ebbfa47
Enhance `getDerivedSignUpCountryCode` function to support email-based…
mantrakp04 Mar 9, 2026
cabefdd
Refactor user schema to centralize metadata for admin restrictions an…
mantrakp04 Mar 9, 2026
33dd37a
Add KMS script to package.json for managing port processes
mantrakp04 Mar 9, 2026
23bd2aa
Implement country code selection component in sign-up rules and updat…
mantrakp04 Mar 9, 2026
6db97e7
Update user test cases to include country_code and risk_scores fields…
mantrakp04 Mar 9, 2026
7de1a49
Update user and OAuth test cases to include country_code and risk_sco…
mantrakp04 Mar 9, 2026
180c1a7
Update team membership and user tests to reflect changes in response …
mantrakp04 Mar 9, 2026
0b962fe
Enhance CEL expression evaluation tests for country code handling. Ad…
mantrakp04 Mar 10, 2026
bd51d88
Refactor user creation logic to improve handling of restricted user a…
mantrakp04 Mar 10, 2026
1257021
Merge branch 'fraud-protection' into fraud-protection-country-code
mantrakp04 Mar 10, 2026
9c5eda1
Merge branch 'fraud-protection' into fraud-protection-country-code
mantrakp04 Mar 10, 2026
7d42522
Merge branch 'fraud-protection' into fraud-protection-country-code
mantrakp04 Mar 10, 2026
5e4da43
Merge branch 'fraud-protection' into fraud-protection-country-code
mantrakp04 Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add nullable sign-up country code to ProjectUser
ALTER TABLE "ProjectUser" ADD COLUMN "countryCode" TEXT;
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof preMigration>>) => {
const rows = await sql`
SELECT "countryCode"
FROM "ProjectUser"
WHERE "projectUserId" = ${ctx.userId}::uuid
`;

expect(rows).toHaveLength(1);
expect(rows[0].countryCode).toBeNull();
};
1 change: 1 addition & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,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[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -248,7 +248,7 @@ const handler = createSmartRouteHandler({
tenancyId: outerInfo.tenancyId,
providerId: provider.id,
providerAccountId: userInfo.accountId,
email: userInfo.email,
email: userInfo.email ?? null,
projectUserId,
});

Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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,
Comment thread
mantrakp04 marked this conversation as resolved.
// Note: Request context not easily available in OAuth callback
// TODO: Pass IP and user agent from stored OAuth state if needed
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { VerificationCodeType } from "@/generated/prisma/client";
import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-channel";
import { sendEmailFromDefaultTemplate } from "@/lib/emails";
import { getSoleTenancyFromProjectBranch, Tenancy } from "@/lib/tenancies";
import { createAuthTokens } from "@/lib/tokens";
import { createOrUpgradeAnonymousUserWithRules } from "@/lib/users";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@/generated/prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { emailSchema, signInResponseSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
Expand Down Expand Up @@ -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
Comment thread
mantrakp04 marked this conversation as resolved.
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export const POST = createSmartRouteHandler({
[KnownErrors.UserWithEmailAlreadyExists],
{
authMethod: 'password',
oauthProvider: null,
ipAddress: null,
countryCode: null,
}
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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";
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;
Expand All @@ -18,13 +21,14 @@ export const POST = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}),
body: yupObject({
email: yupString().optional(),
email: yupString().nullable().defined(),
auth_method: yupString().oneOf(AUTH_METHODS).defined(),
oauth_provider: yupString().optional(),
oauth_provider: 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(),
}).defined(),
}).optional(),
}).defined(),
}),
response: yupObject({
Expand All @@ -34,6 +38,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({
Expand Down Expand Up @@ -62,14 +67,30 @@ export const POST = createSmartRouteHandler({
}).defined(),
}),
handler: async (req) => {
const context = createSignUpRuleContext({
email: req.body.email,
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,
Comment thread
mantrakp04 marked this conversation as resolved.
},
};
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);

Expand All @@ -80,6 +101,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: {
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/app/api/latest/users/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
}),
Expand Down
Loading
Loading