Turnstile integration for fraud protection (#1239)

Enhances sign-up process with Turnstile integration for fraud
protection. Builds on top of fraud-protection-temp-emails.

Made with [Cursor](https://cursor.com)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Cloudflare Turnstile bot-protection across signup/sign-in flows
(including SDK JSON mode).
  * Email deliverability checks via Emailable.
* Sign-up risk scoring with persisted risk metrics and country code
tracking.
* UI: country-code selector, risk-score editing in user details, users
list refresh button, and Turnstile signup demo pages.

* **Bug Fixes**
  * Use actual sign-up timestamp for reporting/metrics.

* **Documentation**
* Expanded knowledge base on Turnstile, risk scoring, and env
configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konstantin Wohlwend <[email protected]>
Co-authored-by: BilalG1 <[email protected]>
Co-authored-by: Armaan Jain <[email protected]>
Co-authored-by: nams1570 <[email protected]>
This commit is contained in:
Mantra
2026-03-20 21:26:45 +00:00
committed by GitHub
co-authored by Konstantin Wohlwend BilalG1 Armaan Jain nams1570
parent 1e44a8c5ba
commit e59a70783e
103 changed files with 7501 additions and 1338 deletions
@@ -91,7 +91,7 @@ export const DEFAULT_DB_SYNC_MAPPINGS = {
),
false
) AS "primary_email_verified",
"ProjectUser"."createdAt" AS "signed_up_at",
COALESCE("ProjectUser"."signedUpAt", "ProjectUser"."createdAt") AS "signed_up_at",
COALESCE("ProjectUser"."clientMetadata", '{}'::jsonb) AS "client_metadata",
COALESCE("ProjectUser"."clientReadOnlyMetadata", '{}'::jsonb) AS "client_read_only_metadata",
COALESCE("ProjectUser"."serverMetadata", '{}'::jsonb) AS "server_metadata",
@@ -167,7 +167,7 @@ export const DEFAULT_DB_SYNC_MAPPINGS = {
),
false
) AS "primary_email_verified",
"ProjectUser"."createdAt" AS "signed_up_at",
COALESCE("ProjectUser"."signedUpAt", "ProjectUser"."createdAt") AS "signed_up_at",
COALESCE("ProjectUser"."clientMetadata", '{}'::jsonb) AS "client_metadata",
COALESCE("ProjectUser"."clientReadOnlyMetadata", '{}'::jsonb) AS "client_read_only_metadata",
COALESCE("ProjectUser"."serverMetadata", '{}'::jsonb) AS "server_metadata",
@@ -0,0 +1,341 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { KnownErrors } from "../known-errors";
import { InternalSession } from "../sessions";
import { Result } from "../utils/results";
import { StackClientInterface } from "./client-interface";
function createClientInterface() {
return new StackClientInterface({
clientVersion: "test",
getBaseUrl: () => "https://api.example.com",
extraRequestHeaders: {},
projectId: "project-id",
publishableClientKey: "publishable-client-key",
});
}
function createSession() {
return new InternalSession({
refreshAccessTokenCallback: async () => null,
refreshToken: null,
accessToken: null,
});
}
function createJsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
}
function createKnownErrorResponse(error: InstanceType<typeof KnownErrors[keyof typeof KnownErrors]>): Response {
return new Response(JSON.stringify({
code: error.errorCode,
message: error.message,
details: error.details,
}), {
status: error.statusCode,
headers: {
"Content-Type": "application/json",
"x-stack-known-error": error.errorCode,
},
});
}
function getRequestBody(fetchMock: { mock: { calls: unknown[][] } }): Record<string, unknown> {
const requestInit = fetchMock.mock.calls[0]?.[1];
if (requestInit == null || typeof requestInit !== "object" || !("body" in requestInit)) {
throw new Error("Expected request init to include a body");
}
const requestBody = requestInit.body;
if (requestBody == null || typeof requestBody !== "string") {
throw new Error("Expected request body to be a JSON string");
}
const parsed = JSON.parse(requestBody);
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Expected parsed request body to be an object");
}
return parsed;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("StackClientInterface bot challenge compatibility", () => {
it("omits bot challenge from magic link requests when no token is provided", async () => {
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
vi.stubGlobal("fetch", fetchMock);
const iface = createClientInterface();
await iface.sendMagicLinkEmail("[email protected]", "https://app.example.com/callback");
expect(getRequestBody(fetchMock)).toStrictEqual({
email: "[email protected]",
callback_url: "https://app.example.com/callback",
});
});
it("serializes visible bot challenge retry fields for magic link requests", async () => {
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
vi.stubGlobal("fetch", fetchMock);
const iface = createClientInterface();
await iface.sendMagicLinkEmail("[email protected]", "https://app.example.com/callback", {
token: " visible-token ",
phase: "visible",
});
expect(getRequestBody(fetchMock)).toStrictEqual({
email: "[email protected]",
callback_url: "https://app.example.com/callback",
bot_challenge_token: "visible-token",
bot_challenge_phase: "visible",
});
});
it("serializes bot challenge unavailability for magic link requests", async () => {
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
vi.stubGlobal("fetch", fetchMock);
const iface = createClientInterface();
await iface.sendMagicLinkEmail("[email protected]", "https://app.example.com/callback", {
phase: "visible",
});
expect(getRequestBody(fetchMock)).toStrictEqual({
email: "[email protected]",
callback_url: "https://app.example.com/callback",
bot_challenge_unavailable: "true",
});
});
it("serializes explicit bot challenge unavailability for magic link requests", async () => {
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
vi.stubGlobal("fetch", fetchMock);
const iface = createClientInterface();
await iface.sendMagicLinkEmail("[email protected]", "https://app.example.com/callback", {
unavailable: true,
});
expect(getRequestBody(fetchMock)).toStrictEqual({
email: "[email protected]",
callback_url: "https://app.example.com/callback",
bot_challenge_unavailable: "true",
});
});
it("returns BotChallengeFailed as a Result error for magic link requests", async () => {
const fetchMock = vi.fn(async () => createKnownErrorResponse(
new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"),
));
vi.stubGlobal("fetch", fetchMock);
const iface = createClientInterface();
const result = await iface.sendMagicLinkEmail("[email protected]", "https://app.example.com/callback", {
phase: "visible",
});
expect(result.status).toBe("error");
if (result.status !== "error") {
throw new Error("Expected magic link request to fail with BotChallengeFailed");
}
expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed);
});
it("omits bot challenge from credential signup requests when no token is provided", async () => {
const fetchMock = vi.fn(async () => createJsonResponse({
access_token: "access-token",
refresh_token: "refresh-token",
}));
vi.stubGlobal("fetch", fetchMock);
const iface = createClientInterface();
await iface.signUpWithCredential(
"[email protected]",
"password",
undefined,
createSession(),
undefined,
);
expect(getRequestBody(fetchMock)).toStrictEqual({
email: "[email protected]",
password: "password",
});
});
it("returns BotChallengeFailed as a Result error for credential signup requests", async () => {
const fetchMock = vi.fn(async () => createKnownErrorResponse(
new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"),
));
vi.stubGlobal("fetch", fetchMock);
const iface = createClientInterface();
const result = await iface.signUpWithCredential(
"[email protected]",
"password",
undefined,
createSession(),
{
phase: "visible",
},
);
expect(result.status).toBe("error");
if (result.status !== "error") {
throw new Error("Expected credential signup to fail with BotChallengeFailed");
}
expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed);
});
it("omits bot challenge from OAuth URLs when no token is provided", async () => {
const iface = createClientInterface();
const oauthUrl = await iface.getOAuthUrl({
provider: "github",
redirectUrl: "https://app.example.com/oauth/callback",
errorRedirectUrl: "https://app.example.com/error",
codeChallenge: "code-challenge",
state: "state",
type: "authenticate",
session: createSession(),
});
expect(new URL(oauthUrl).searchParams.has("bot_challenge_token")).toBe(false);
});
it("serializes visible bot challenge retry fields in OAuth URLs", async () => {
const iface = createClientInterface();
const oauthUrl = await iface.getOAuthUrl({
provider: "github",
redirectUrl: "https://app.example.com/oauth/callback",
errorRedirectUrl: "https://app.example.com/error",
codeChallenge: "code-challenge",
state: "state",
type: "authenticate",
botChallenge: {
token: "visible-token",
phase: "visible",
},
session: createSession(),
});
expect(Object.fromEntries(new URL(oauthUrl).searchParams.entries())).toMatchObject({
bot_challenge_token: "visible-token",
bot_challenge_phase: "visible",
});
});
it("serializes bot challenge unavailability in OAuth URLs", async () => {
const iface = createClientInterface();
const oauthUrl = await iface.getOAuthUrl({
provider: "github",
redirectUrl: "https://app.example.com/oauth/callback",
errorRedirectUrl: "https://app.example.com/error",
codeChallenge: "code-challenge",
state: "state",
type: "authenticate",
botChallenge: {
phase: "visible",
},
session: createSession(),
});
expect(Object.fromEntries(new URL(oauthUrl).searchParams.entries())).toMatchObject({
bot_challenge_unavailable: "true",
});
});
it("authorizes OAuth via a JSON response instead of relying on manual redirects", async () => {
const fetchCalls: [input: RequestInfo | URL, init?: RequestInit][] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push([input, init]);
return createJsonResponse({
location: "https://accounts.example.com/oauth/authorize",
});
});
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("window", {} as Window & typeof globalThis);
const iface = createClientInterface();
const result = await iface.authorizeOAuth({
provider: "github",
redirectUrl: "https://app.example.com/oauth/callback",
errorRedirectUrl: "https://app.example.com/error",
codeChallenge: "code-challenge",
state: "state",
type: "authenticate",
session: createSession(),
});
expect(Result.orThrow(result)).toBe("https://accounts.example.com/oauth/authorize");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [requestUrl, requestInit] = fetchCalls[0] ?? [];
if (!(typeof requestUrl === "string" || requestUrl instanceof URL)) {
throw new Error("Expected authorizeOAuth to call fetch with a URL");
}
expect(new URL(requestUrl.toString()).searchParams.get("stack_response_mode")).toBe("json");
expect(requestInit).toMatchObject({
method: "GET",
});
expect(requestInit).not.toHaveProperty("credentials");
});
it("returns BotChallengeFailed as a Result error for OAuth authorization", async () => {
const fetchMock = vi.fn(async () => createKnownErrorResponse(
new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"),
));
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("window", {} as Window & typeof globalThis);
const iface = createClientInterface();
const result = await iface.authorizeOAuth({
provider: "github",
redirectUrl: "https://app.example.com/oauth/callback",
errorRedirectUrl: "https://app.example.com/error",
codeChallenge: "code-challenge",
state: "state",
type: "authenticate",
session: createSession(),
});
expect(result.status).toBe("error");
if (result.status !== "error") {
throw new Error("Expected OAuth authorization to fail with BotChallengeFailed");
}
expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed);
});
it("serializes bot challenge unavailability for credential signup requests", async () => {
const fetchMock = vi.fn(async () => createJsonResponse({
access_token: "access-token",
refresh_token: "refresh-token",
}));
vi.stubGlobal("fetch", fetchMock);
const iface = createClientInterface();
await iface.signUpWithCredential(
"[email protected]",
"password",
undefined,
createSession(),
{
phase: "visible",
},
);
expect(getRequestBody(fetchMock)).toStrictEqual({
email: "[email protected]",
password: "password",
bot_challenge_unavailable: "true",
});
});
});
@@ -47,6 +47,67 @@ export type ClientInterfaceOptions = {
projectOwnerSession: InternalSession | (() => Promise<string | null>),
});
type BotChallengeInput = {
token?: string,
phase?: "invisible" | "visible",
unavailable?: true,
};
const botChallengeKnownErrors = [
KnownErrors.BotChallengeRequired,
KnownErrors.BotChallengeFailed,
] as const;
function isBotChallengeKnownError(error: unknown): error is KnownErrors["BotChallengeRequired"] | KnownErrors["BotChallengeFailed"] {
return KnownErrors.BotChallengeRequired.isInstance(error) || KnownErrors.BotChallengeFailed.isInstance(error);
}
function getBotChallengeRequestFields(botChallenge: BotChallengeInput | undefined, context: string) {
if (botChallenge?.unavailable) {
if (botChallenge.token != null || botChallenge.phase != null) {
throw new StackAssertionError(`${context} bot challenge unavailability cannot be combined with a token or phase.`);
}
return {
bot_challenge_unavailable: "true" as const,
};
}
const challengeToken = botChallenge?.token?.trim() || undefined;
if (botChallenge?.phase === "visible") {
if (challengeToken == null) {
// Backward-compatible fallback for older callers; prefer `unavailable: true`.
return {
bot_challenge_unavailable: "true",
};
}
return {
bot_challenge_token: challengeToken,
bot_challenge_phase: "visible" as const,
};
}
if (challengeToken == null) {
if (botChallenge?.phase != null) {
throw new StackAssertionError(`${context} bot challenge phase options require a token.`);
}
return {};
}
if (botChallenge?.phase == null) {
return {
bot_challenge_token: challengeToken,
};
}
return {
bot_challenge_token: challengeToken,
bot_challenge_phase: "invisible" as const,
};
}
export class StackClientInterface {
private pendingNetworkDiagnostics?: ReturnType<StackClientInterface["_runNetworkDiagnosticsInner"]>;
@@ -590,7 +651,8 @@ export class StackClientInterface {
async sendMagicLinkEmail(
email: string,
callbackUrl: string,
): Promise<Result<{ nonce: string }, KnownErrors["RedirectUrlNotWhitelisted"]>> {
botChallenge?: BotChallengeInput,
): Promise<Result<{ nonce: string }, KnownErrors["RedirectUrlNotWhitelisted"] | KnownErrors["BotChallengeRequired"] | KnownErrors["BotChallengeFailed"]>> {
const res = await this.sendClientRequestAndCatchKnownError(
"/auth/otp/send-sign-in-code",
{
@@ -601,10 +663,11 @@ export class StackClientInterface {
body: JSON.stringify({
email,
callback_url: callbackUrl,
...getBotChallengeRequestFields(botChallenge, "Magic link sign-in"),
}),
},
null,
[KnownErrors.RedirectUrlNotWhitelisted]
[KnownErrors.RedirectUrlNotWhitelisted, ...botChallengeKnownErrors]
);
if (res.status === "error") {
@@ -906,7 +969,8 @@ export class StackClientInterface {
password: string,
emailVerificationRedirectUrl: string | undefined,
session: InternalSession,
): Promise<Result<{ accessToken: string, refreshToken: string }, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"]>> {
botChallenge?: BotChallengeInput,
): Promise<Result<{ accessToken: string, refreshToken: string }, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"] | KnownErrors["BotChallengeRequired"] | KnownErrors["BotChallengeFailed"]>> {
const res = await this.sendClientRequestAndCatchKnownError(
"/auth/password/sign-up",
{
@@ -918,10 +982,11 @@ export class StackClientInterface {
email,
password,
verification_callback_url: emailVerificationRedirectUrl,
...getBotChallengeRequestFields(botChallenge, "Credential sign-up"),
}),
},
session,
[KnownErrors.UserWithEmailAlreadyExists, KnownErrors.PasswordRequirementsNotMet]
[KnownErrors.UserWithEmailAlreadyExists, KnownErrors.PasswordRequirementsNotMet, ...botChallengeKnownErrors]
);
if (res.status === "error") {
@@ -1049,6 +1114,7 @@ export class StackClientInterface {
state: string,
type: "authenticate" | "link",
providerScope?: string,
botChallenge?: BotChallengeInput,
session: InternalSession,
}
): Promise<string> {
@@ -1091,10 +1157,71 @@ export class StackClientInterface {
if (options.providerScope) {
url.searchParams.set("provider_scope", options.providerScope);
}
for (const [key, value] of Object.entries(getBotChallengeRequestFields(options.botChallenge, `OAuth ${options.type}`))) {
url.searchParams.set(key, value);
}
return url.toString();
}
async authorizeOAuth(options: {
provider: string,
redirectUrl: string,
errorRedirectUrl: string,
afterCallbackRedirectUrl?: string,
codeChallenge: string,
state: string,
type: "authenticate" | "link",
providerScope?: string,
botChallenge?: BotChallengeInput,
session: InternalSession,
}): Promise<Result<string, KnownErrors["BotChallengeRequired"] | KnownErrors["BotChallengeFailed"]>> {
if (typeof window === "undefined") {
throw new StackAssertionError("authorizeOAuth can currently only be called in a browser environment");
}
await this.options.prepareRequest?.();
const url = new URL(await this.getOAuthUrl(options));
url.searchParams.set("stack_response_mode", "json");
let rawRes;
try {
rawRes = await fetch(url, {
method: "GET",
});
} catch (error) {
if (error instanceof TypeError) {
throw await this._createNetworkError(error, options.session, "client");
}
throw error;
}
const processedResponse = await this._processResponse(rawRes);
if (processedResponse.status === "error") {
if (isBotChallengeKnownError(processedResponse.error)) {
return Result.error(processedResponse.error);
}
throw processedResponse.error;
}
if (processedResponse.data.status !== 200) {
throw new StackAssertionError(`OAuth authorize returned an unexpected status: ${processedResponse.data.status}`);
}
const body = await processedResponse.data.json();
if (body == null || typeof body !== "object" || Array.isArray(body)) {
throw new StackAssertionError("OAuth authorize response body must be an object", { body });
}
const location = body.location;
if (typeof location !== "string") {
throw new StackAssertionError("OAuth authorize response is missing a redirect location", { body });
}
return Result.ok(location);
}
async callOAuthCallback(options: {
oauthParams: URLSearchParams,
redirectUri: string,
@@ -1,9 +1,46 @@
import type { InferType } from "yup";
import * as yup from "yup";
import { CrudTypeOf, createCrud } from "../../crud";
import * as fieldSchema from "../../schema-fields";
import { WebhookEvent } from "../webhooks";
import { teamsCrudServerReadSchema } from "./teams";
const restrictedByAdminMeta = {
restricted_by_admin: { 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: { openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } },
restricted_by_admin_private_details: { openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } },
} as const;
const countryCodeMeta = { openapiField: { description: 'Best-effort ISO country code captured at sign-up time from request geo headers.', exampleValue: "US" } } as const;
export const riskScoreFieldSchema = fieldSchema.yupNumber().integer().min(0).max(100).defined();
export const signUpRiskScoresSchema = fieldSchema.yupObject({
sign_up: fieldSchema.yupObject({
bot: riskScoreFieldSchema,
free_trial_abuse: riskScoreFieldSchema,
}).defined(),
});
export type SignUpRiskScoresCrud = InferType<typeof signUpRiskScoresSchema>["sign_up"];
const oauthProviderBaseFields = {
id: fieldSchema.yupString().defined(),
account_id: fieldSchema.yupString().defined(),
};
const hiddenFieldMeta = { openapiField: { hidden: true } } as const;
function restrictedByAdminConsistencyTest(this: yup.TestContext<any>, value: any) {
if (value == null) return true;
if (value.restricted_by_admin !== true) {
if (value.restricted_by_admin_reason != null) {
return this.createError({ message: "restricted_by_admin_reason must be null when restricted_by_admin is not true" });
}
if (value.restricted_by_admin_private_details != null) {
return this.createError({ message: "restricted_by_admin_private_details must be null when restricted_by_admin is not true" });
}
}
return true;
}
export const usersCrudServerUpdateSchema = fieldSchema.yupObject({
display_name: fieldSchema.userDisplayNameSchema.optional(),
profile_image_url: fieldSchema.profileImageUrlSchema.nullable().optional(),
@@ -20,25 +57,15 @@ export const usersCrudServerUpdateSchema = fieldSchema.yupObject({
totp_secret_base64: fieldSchema.userTotpSecretMutationSchema.optional(),
selected_team_id: fieldSchema.selectedTeamIdSchema.nullable().optional(),
is_anonymous: fieldSchema.yupBoolean().oneOf([false]).optional(),
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 } }),
restricted_by_admin: fieldSchema.yupBoolean().optional().meta(restrictedByAdminMeta.restricted_by_admin),
restricted_by_admin_reason: fieldSchema.yupString().nullable().optional().meta(restrictedByAdminMeta.restricted_by_admin_reason),
restricted_by_admin_private_details: fieldSchema.yupString().nullable().optional().meta(restrictedByAdminMeta.restricted_by_admin_private_details),
country_code: fieldSchema.countryCodeSchema.nullable().optional().meta(countryCodeMeta),
risk_scores: signUpRiskScoresSchema.optional(),
}).defined().test(
"restricted_by_admin_consistency",
"When restricted_by_admin is not true, reason and private_details must be null",
function(this: yup.TestContext<any>, value: any) {
if (value == null) return true;
// If restricted_by_admin is false or missing, both reason and private_details must be null
if (value.restricted_by_admin !== true) {
if (value.restricted_by_admin_reason != null) {
return this.createError({ message: "restricted_by_admin_reason must be null when restricted_by_admin is not true" });
}
if (value.restricted_by_admin_private_details != null) {
return this.createError({ message: "restricted_by_admin_private_details must be null when restricted_by_admin is not true" });
}
}
return true;
}
restrictedByAdminConsistencyTest,
);
export const usersCrudServerReadSchema = fieldSchema.yupObject({
@@ -61,15 +88,16 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({
is_anonymous: fieldSchema.yupBoolean().defined(),
is_restricted: fieldSchema.yupBoolean().defined().meta({ openapiField: { description: 'Whether the user is in restricted state (has signed up but not completed onboarding requirements)', exampleValue: false } }),
restricted_reason: fieldSchema.restrictedReasonSchema.nullable().defined().meta({ openapiField: { description: 'The reason why the user is restricted (e.g., type: "email_not_verified", "anonymous", or "restricted_by_administrator"), null if not restricted', exampleValue: null } }),
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 } }),
restricted_by_admin: fieldSchema.yupBoolean().defined().meta(restrictedByAdminMeta.restricted_by_admin),
restricted_by_admin_reason: fieldSchema.yupString().nullable().defined().meta(restrictedByAdminMeta.restricted_by_admin_reason),
restricted_by_admin_private_details: fieldSchema.yupString().nullable().defined().meta(restrictedByAdminMeta.restricted_by_admin_private_details),
country_code: fieldSchema.countryCodeSchema.nullable().defined().meta(countryCodeMeta),
risk_scores: signUpRiskScoresSchema.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(),
account_id: fieldSchema.yupString().defined(),
...oauthProviderBaseFields,
email: fieldSchema.yupString().nullable(),
}).defined()).defined().meta({ openapiField: { hidden: true } }),
}).defined()).defined().meta(hiddenFieldMeta),
/**
* @deprecated
@@ -85,27 +113,14 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({
}).test(
"restricted_by_admin_consistency",
"When restricted_by_admin is not true, reason and private_details must be null",
function(this: yup.TestContext<any>, value: any) {
if (value == null) return true;
// If restricted_by_admin is false or missing, both reason and private_details must be null
if (value.restricted_by_admin !== true) {
if (value.restricted_by_admin_reason != null) {
return this.createError({ message: "restricted_by_admin_reason must be null when restricted_by_admin is not true" });
}
if (value.restricted_by_admin_private_details != null) {
return this.createError({ message: "restricted_by_admin_private_details must be null when restricted_by_admin is not true" });
}
}
return true;
}
restrictedByAdminConsistencyTest,
);
export const usersCrudServerCreateSchema = usersCrudServerUpdateSchema.omit(['selected_team_id']).concat(fieldSchema.yupObject({
oauth_providers: fieldSchema.yupArray(fieldSchema.yupObject({
id: fieldSchema.yupString().defined(),
account_id: fieldSchema.yupString().defined(),
...oauthProviderBaseFields,
email: fieldSchema.yupString().nullable().defined().default(null),
}).defined()).optional().meta({ openapiField: { hidden: true } }),
}).defined()).optional().meta(hiddenFieldMeta),
is_anonymous: fieldSchema.yupBoolean().optional(),
}).defined());
@@ -146,25 +161,20 @@ export const usersCrud = createCrud({
});
export type UsersCrud = CrudTypeOf<typeof usersCrud>;
export const userCreatedWebhookEvent = {
type: "user.created",
schema: usersCrud.server.readSchema,
metadata: {
summary: "User Created",
description: "This event is triggered when a user is created.",
tags: ["Users"],
},
} satisfies WebhookEvent<typeof usersCrud.server.readSchema>;
function userWebhookEvent<S extends yup.Schema>(action: string, schema: S): WebhookEvent<S> {
return {
type: `user.${action}`,
schema,
metadata: {
summary: `User ${action[0].toUpperCase()}${action.slice(1)}`,
description: `This event is triggered when a user is ${action}.`,
tags: ["Users"],
},
};
}
export const userUpdatedWebhookEvent = {
type: "user.updated",
schema: usersCrud.server.readSchema,
metadata: {
summary: "User Updated",
description: "This event is triggered when a user is updated.",
tags: ["Users"],
},
} satisfies WebhookEvent<typeof usersCrud.server.readSchema>;
export const userCreatedWebhookEvent = userWebhookEvent("created", usersCrud.server.readSchema);
export const userUpdatedWebhookEvent = userWebhookEvent("updated", usersCrud.server.readSchema);
const webhookUserDeletedSchema = fieldSchema.yupObject({
id: fieldSchema.userIdSchema.defined(),
@@ -172,13 +182,4 @@ const webhookUserDeletedSchema = fieldSchema.yupObject({
id: fieldSchema.yupString().defined(),
})).defined(),
}).defined();
export const userDeletedWebhookEvent = {
type: "user.deleted",
schema: webhookUserDeletedSchema,
metadata: {
summary: "User Deleted",
description: "This event is triggered when a user is deleted.",
tags: ["Users"],
},
} satisfies WebhookEvent<typeof webhookUserDeletedSchema>;
export const userDeletedWebhookEvent = userWebhookEvent("deleted", webhookUserDeletedSchema);
@@ -752,6 +752,29 @@ const SignUpRejected = createKnownErrorConstructor(
(json: any) => [json.message] as const,
);
const BotChallengeRequired = createKnownErrorConstructor(
KnownError,
"BOT_CHALLENGE_REQUIRED",
() => [
409,
"An additional bot challenge is required before sign-up can continue.",
] as const,
() => [] as const,
);
const BotChallengeFailed = createKnownErrorConstructor(
KnownError,
"BOT_CHALLENGE_FAILED",
(message: string) => [
400,
message,
{
message,
},
] as const,
(json: any) => [json.message] as const,
);
const PasswordAuthenticationNotEnabled = createKnownErrorConstructor(
KnownError,
"PASSWORD_AUTHENTICATION_NOT_ENABLED",
@@ -1879,6 +1902,8 @@ export const KnownErrors = {
BranchDoesNotExist,
SignUpNotEnabled,
SignUpRejected,
BotChallengeRequired,
BotChallengeFailed,
PasswordAuthenticationNotEnabled,
PasskeyAuthenticationNotEnabled,
AnonymousAccountsNotEnabled,
@@ -8,9 +8,12 @@ import { decodeBasicAuthorizationHeader } from "./utils/http";
import { allProviders } from "./utils/oauth";
import { deepPlainClone, omit, typedFromEntries } from "./utils/objects";
import { deindent } from "./utils/strings";
import { ISO_3166_ALPHA_2_COUNTRY_CODES, isValidCountryCode, normalizeCountryCode } from "./utils/country-codes";
import { isValidHostnameWithWildcards, isValidUrl } from "./utils/urls";
import { isUuid } from "./utils/uuids";
export { ISO_3166_ALPHA_2_COUNTRY_CODES, isValidCountryCode, normalizeCountryCode, validateCountryCode, validCountryCodeSet } from "./utils/country-codes";
const MAX_IMAGE_SIZE_BASE64_BYTES = 1_000_000; // 1MB
declare module "yup" {
@@ -431,6 +434,15 @@ export const base64Schema = yupString().test("is-base64", (params) => `${params.
return isBase64(value);
});
export const passwordSchema = yupString().max(70);
export const countryCodeSchema = yupString().transform((value) => typeof value === "string" ? normalizeCountryCode(value) : value).test({
name: "country-code",
message: (params) => `${params.path} must be a valid ISO 3166-1 alpha-2 country code`,
test: (value) => value == null || isValidCountryCode(value),
});
import.meta.vitest?.test("countryCodeSchema", async ({ expect }) => {
await expect(countryCodeSchema.validate(" us ")).resolves.toBe("US");
await expect(countryCodeSchema.validate("usa")).rejects.toThrow("must be a valid ISO 3166-1 alpha-2 country code");
});
export const intervalSchema = yupTuple<Interval>([yupNumber().min(0).integer().defined(), yupString().oneOf(['millisecond', 'second', 'minute', 'hour', 'day', 'week', 'month', 'year']).defined()]);
export const dayIntervalSchema = yupTuple<DayInterval>([yupNumber().min(0).integer().defined(), yupString().oneOf(['day', 'week', 'month', 'year']).defined()]);
export const intervalOrNeverSchema = yupUnion(intervalSchema.defined(), yupString().oneOf(['never']).defined());
@@ -0,0 +1,8 @@
export const signUpAuthMethodValues = [
"password",
"otp",
"oauth",
"passkey",
] as const;
export type SignUpAuthMethod = typeof signUpAuthMethodValues[number];
@@ -0,0 +1,92 @@
import { signUpAuthMethodValues } from "./auth-methods";
import { standardProviders } from "./oauth";
// ── Types ──────────────────────────────────────────────────────────────
export type ConditionField =
| 'email'
| 'countryCode'
| 'emailDomain'
| 'authMethod'
| 'oauthProvider'
| 'riskScores.bot'
| 'riskScores.free_trial_abuse';
export type ConditionOperator =
| 'equals'
| 'not_equals'
| 'greater_than'
| 'greater_or_equal'
| 'less_than'
| 'less_or_equal'
| 'matches'
| 'ends_with'
| 'starts_with'
| 'contains'
| 'in_list';
// ── Helpers ────────────────────────────────────────────────────────────
export function isNumericField(field: ConditionField): boolean {
return field === 'riskScores.bot' || field === 'riskScores.free_trial_abuse';
}
/**
* Validates a numeric field value is a finite integer within [0, 100].
* Returns null if valid, or an error message string if invalid.
*/
export function validateNumericFieldValue(field: string, value: string | number): string | null {
const num = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(num)) {
return `Expected a finite number for field "${field}", got "${String(value)}"`;
}
if (!Number.isInteger(num)) {
return `Expected an integer for field "${field}", got "${String(value)}"`;
}
if (num < 0 || num > 100) {
return `Value for field "${field}" must be between 0 and 100, got ${num}`;
}
return null;
}
export function escapeCelString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export function unescapeCelString(value: string): string {
return value.replace(/\\\\/g, '\\').replace(/\\"/g, '"');
}
// ── Field metadata ─────────────────────────────────────────────────────
const numericOperators: ConditionOperator[] = ['equals', 'not_equals', 'greater_than', 'greater_or_equal', 'less_than', 'less_or_equal'];
const enumOperators: ConditionOperator[] = ['equals', 'not_equals', 'in_list'];
const stringOperators: ConditionOperator[] = ['equals', 'not_equals', 'contains', 'starts_with', 'ends_with', 'matches', 'in_list'];
export type FieldMetadataEntry = {
label: string,
operators: ConditionOperator[],
predefinedValues?: string[],
};
export const fieldMetadata: Record<ConditionField, FieldMetadataEntry> = {
email: { label: 'Email', operators: stringOperators },
countryCode: { label: 'Country Code', operators: enumOperators },
emailDomain: { label: 'Email Domain', operators: stringOperators },
authMethod: { label: 'Auth Method', operators: enumOperators, predefinedValues: [...signUpAuthMethodValues] },
oauthProvider: { label: 'OAuth Provider', operators: enumOperators, predefinedValues: [...standardProviders] },
'riskScores.bot': { label: 'Risk Score: Bot', operators: numericOperators },
'riskScores.free_trial_abuse': { label: 'Risk Score: Free Trial Abuse', operators: numericOperators },
};
export const conditionFields = Object.keys(fieldMetadata) as ConditionField[];
export const conditionOperators: ConditionOperator[] = [
'equals', 'not_equals', 'greater_than', 'greater_or_equal',
'less_than', 'less_or_equal', 'matches', 'ends_with',
'starts_with', 'contains', 'in_list',
];
export function getOperatorsForField(field: ConditionField): ConditionOperator[] {
return fieldMetadata[field].operators;
}
@@ -0,0 +1,67 @@
export const ISO_3166_ALPHA_2_COUNTRY_CODES = [
"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ",
"BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ",
"CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ",
"DE", "DJ", "DK", "DM", "DO", "DZ",
"EC", "EE", "EG", "EH", "ER", "ES", "ET",
"FI", "FJ", "FK", "FM", "FO", "FR",
"GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY",
"HK", "HM", "HN", "HR", "HT", "HU",
"ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT",
"JE", "JM", "JO", "JP",
"KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ",
"LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY",
"MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ",
"NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ",
"OM",
"PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY",
"QA",
"RE", "RO", "RS", "RU", "RW",
"SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ",
"TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ",
"UA", "UG", "UM", "US", "UY", "UZ",
"VA", "VC", "VE", "VG", "VI", "VN", "VU",
"WF", "WS",
"YE", "YT",
"ZA", "ZM", "ZW",
] as const;
export type Iso3166Alpha2CountryCode = typeof ISO_3166_ALPHA_2_COUNTRY_CODES[number];
export const validCountryCodeSet = new Set<string>(ISO_3166_ALPHA_2_COUNTRY_CODES);
export function normalizeCountryCode(countryCode: string): string {
return countryCode.trim().toUpperCase();
}
export function isValidCountryCode(countryCode: string): boolean {
return validCountryCodeSet.has(normalizeCountryCode(countryCode));
}
/**
* Validates and normalizes a country code value (single string or array).
* Returns null if valid, or an error message string if invalid.
*/
export function validateCountryCode(value: string | string[]): string | null {
const values = Array.isArray(value) ? value : [value];
if (values.length === 0) {
return "At least one country code is required";
}
return values.every(v => isValidCountryCode(v))
? null
: "Country code must be a valid ISO 3166-1 alpha-2 code";
}
import.meta.vitest?.test("country codes", ({ expect }) => {
expect(ISO_3166_ALPHA_2_COUNTRY_CODES).toHaveLength(249);
expect(normalizeCountryCode(" us ")).toBe("US");
expect(isValidCountryCode("us")).toBe(true);
expect(isValidCountryCode("zz")).toBe(false);
expect(isValidCountryCode("usa")).toBe(false);
expect(validateCountryCode("US")).toBeNull();
expect(validateCountryCode("zz")).toBe("Country code must be a valid ISO 3166-1 alpha-2 code");
expect(validateCountryCode(["US", "CA"])).toBeNull();
expect(validateCountryCode([])).toBe("At least one country code is required");
expect(validateCountryCode(["US", "ZZ"])).toBe("Country code must be a valid ISO 3166-1 alpha-2 code");
});
@@ -0,0 +1,106 @@
import { StackAssertionError } from "./errors";
import { TurnstileAction } from "./turnstile";
export type TurnstileWidgetId = string;
export type TurnstileTheme = "auto" | "light" | "dark";
export type TurnstileAppearance = "always" | "execute" | "interaction-only";
export type TurnstileExecution = "render" | "execute";
export type TurnstileSize = "invisible" | "flexible" | "normal" | "compact";
export type TurnstileConfig = {
sitekey: string,
action: TurnstileAction,
theme?: TurnstileTheme,
appearance?: TurnstileAppearance,
execution?: TurnstileExecution,
size?: TurnstileSize,
callback: (token: string) => void,
"error-callback": (errorCode?: string) => void,
"expired-callback": () => void,
"timeout-callback"?: () => void,
};
export type TurnstileApi = {
render: (container: HTMLElement, config: TurnstileConfig) => TurnstileWidgetId,
execute?: (widgetId: TurnstileWidgetId) => void,
remove: (widgetId: TurnstileWidgetId) => void,
reset?: (widgetId: TurnstileWidgetId) => void,
};
const TURNSTILE_SCRIPT_BASE_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js";
const TURNSTILE_SCRIPT_LOAD_TIMEOUT_MS = 30_000;
export function isTurnstileApi(value: unknown): value is TurnstileApi {
return typeof value === "object"
&& value !== null
&& "render" in value
&& "remove" in value;
}
export function getTurnstileApi(): TurnstileApi | undefined {
if (typeof window === "undefined") {
return undefined;
}
const maybeTurnstile = Reflect.get(window, "turnstile");
return isTurnstileApi(maybeTurnstile) ? maybeTurnstile : undefined;
}
let turnstileScriptPromise: Promise<void> | null = null;
export function loadTurnstileScript(): Promise<void> {
if (typeof window === "undefined") {
return Promise.reject(new StackAssertionError("Turnstile can only be loaded in the browser"));
}
if (getTurnstileApi()) {
return Promise.resolve();
}
turnstileScriptPromise ??= new Promise<void>((resolve, reject) => {
const rejectAndReset = (err: Error) => {
turnstileScriptPromise = null;
reject(err);
};
const timeout = setTimeout(() => {
rejectAndReset(new Error("Turnstile script load timed out"));
}, TURNSTILE_SCRIPT_LOAD_TIMEOUT_MS);
const resolveAndClearTimeout = () => {
clearTimeout(timeout);
resolve();
};
const existingScript = document.querySelector<HTMLScriptElement>(`script[src^="${TURNSTILE_SCRIPT_BASE_URL}"]`);
if (existingScript) {
// If the Turnstile API is already available (script loaded before our loader ran),
// resolve immediately — the load event may have already fired.
if (getTurnstileApi()) {
resolveAndClearTimeout();
return;
}
existingScript.addEventListener("load", () => resolveAndClearTimeout(), { once: true });
existingScript.addEventListener("error", () => {
existingScript.remove();
clearTimeout(timeout);
rejectAndReset(new Error("Failed to load Turnstile"));
}, { once: true });
return;
}
const script = document.createElement("script");
script.src = `${TURNSTILE_SCRIPT_BASE_URL}?render=explicit`;
script.async = true;
script.defer = true;
script.onload = () => resolveAndClearTimeout();
script.onerror = () => {
script.remove();
clearTimeout(timeout);
rejectAndReset(new Error("Failed to load Turnstile"));
};
document.head.append(script);
});
return turnstileScriptPromise;
}
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadTurnstileScriptMock = vi.fn(() => Promise.resolve());
const renderMock = vi.fn();
const executeMock = vi.fn();
const removeMock = vi.fn();
const captureErrorMock = vi.fn();
vi.mock("./turnstile-browser", () => ({
loadTurnstileScript: loadTurnstileScriptMock,
getTurnstileApi: () => ({
render: renderMock,
execute: executeMock,
remove: removeMock,
}),
}));
vi.mock("./errors", async () => {
const actual = await vi.importActual<typeof import("./errors")>("./errors");
return {
...actual,
captureError: captureErrorMock,
};
});
describe("withBotChallengeFlow", () => {
beforeEach(() => {
vi.clearAllMocks();
loadTurnstileScriptMock.mockResolvedValue(undefined);
renderMock.mockImplementation((_container, config: {
callback: (token: string) => void,
}) => {
config.callback("invisible-token");
return "widget-id";
});
executeMock.mockImplementation(() => {});
removeMock.mockImplementation(() => {});
});
it("throws a bot challenge execution error when the phase-2 visible challenge fails", async () => {
const { BotChallengeExecutionFailedError, withBotChallengeFlow } = await import("./turnstile-flow");
loadTurnstileScriptMock
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("cloudflare unavailable"));
const execute = vi.fn(async ({ token, phase }: { token?: string, phase?: "invisible" | "visible" }) => {
if (token === "invisible-token" && phase === "invisible") {
return { requiresChallenge: true };
}
return { requiresChallenge: false };
});
await expect(withBotChallengeFlow({
visibleSiteKey: "visible-site-key",
invisibleSiteKey: "invisible-site-key",
action: "sign_up_with_credential",
execute,
isChallengeRequired: (result) => result.requiresChallenge,
})).rejects.toBeInstanceOf(BotChallengeExecutionFailedError);
expect(execute).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith({
token: "invisible-token",
phase: "invisible",
});
expect(captureErrorMock).toHaveBeenCalledWith(
"turnstile-flow-visible-challenge-failed",
expect.any(Error),
);
});
it("marks the challenge as unavailable when both phase-1 challenge attempts fail", async () => {
const { withBotChallengeFlow } = await import("./turnstile-flow");
loadTurnstileScriptMock
.mockRejectedValueOnce(new Error("invisible unavailable"))
.mockRejectedValueOnce(new Error("visible unavailable"));
const execute = vi.fn(async ({ unavailable }: { unavailable?: true }) => ({
unavailable,
}));
await expect(withBotChallengeFlow({
visibleSiteKey: "visible-site-key",
invisibleSiteKey: "invisible-site-key",
action: "sign_up_with_credential",
execute,
isChallengeRequired: () => false,
})).resolves.toEqual({ unavailable: true });
expect(execute).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith({ unavailable: true });
expect(captureErrorMock).toHaveBeenCalledWith(
"turnstile-flow-all-challenges-failed",
expect.any(Error),
);
});
});
@@ -0,0 +1,266 @@
import { StackAssertionError, captureError } from "./errors";
import { loadTurnstileScript, getTurnstileApi } from "./turnstile-browser";
import type { TurnstileAction } from "./turnstile";
export class BotChallengeUserCancelledError extends Error {
constructor() {
super("User cancelled the bot challenge");
this.name = "BotChallengeUserCancelledError";
}
}
export class BotChallengeExecutionFailedError extends Error {
constructor(message = "Bot challenge could not be completed", options?: { cause?: unknown }) {
super(message, options);
this.name = "BotChallengeExecutionFailedError";
}
}
// ── Invisible challenge ────────────────────────────────────────────────
const INVISIBLE_TIMEOUT_MS = 30_000;
export async function executeTurnstileInvisible(siteKey: string, action: TurnstileAction): Promise<string> {
await loadTurnstileScript();
const api = getTurnstileApi();
if (!api) throw new StackAssertionError("Turnstile API not available after loadTurnstileScript() resolved");
const container = document.createElement("div");
Object.assign(container.style, { position: "fixed", left: "-9999px", top: "-9999px" });
document.body.appendChild(container);
let widgetId: string | undefined;
try {
return await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Turnstile invisible challenge timed out")),
INVISIBLE_TIMEOUT_MS,
);
const settle = (fn: () => void) => {
clearTimeout(timeout);
fn();
};
widgetId = api.render(container, {
sitekey: siteKey,
action,
size: "invisible",
execution: "execute",
appearance: "execute",
callback: (t) => settle(() => resolve(t)),
"error-callback": () => settle(() => reject(new Error("Turnstile invisible verification failed"))),
"expired-callback": () => settle(() => reject(new Error("Turnstile token expired"))),
"timeout-callback": () => settle(() => reject(new Error("Turnstile challenge timed out"))),
});
api.execute?.(widgetId);
});
} finally {
if (widgetId != null) {
try {
api.remove(widgetId);
} catch (e) {
captureError("turnstile-widget-remove", e instanceof Error ? e : new StackAssertionError("Non-Error thrown during Turnstile widget removal", { cause: e }));
}
}
container.remove();
}
}
// ── Visible challenge overlay ──────────────────────────────────────────
const VISIBLE_TIMEOUT_MS = 120_000;
const OVERLAY_Z_INDEX = "999999";
// Module-level singleton: only one visible overlay can be active at a time.
// If a second challenge is requested while one is showing (e.g. user clicks another
// auth flow), the previous overlay is cancelled with BotChallengeUserCancelledError
// and cleaned up before the new one renders.
let activeOverlay: { cleanup: () => void, reject: (err: Error) => void } | null = null;
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
style: Partial<CSSStyleDeclaration>,
props?: Record<string, string>,
): HTMLElementTagNameMap[K] {
const element = document.createElement(tag);
Object.assign(element.style, style);
if (props) {
for (const [k, v] of Object.entries(props)) {
element.setAttribute(k, v);
}
}
return element;
}
export function showTurnstileVisibleChallenge(siteKey: string, action: TurnstileAction): Promise<string> {
if (activeOverlay) {
activeOverlay.reject(new BotChallengeUserCancelledError());
activeOverlay.cleanup();
activeOverlay = null;
}
return new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("Visible Turnstile challenge timed out"));
}, VISIBLE_TIMEOUT_MS);
const overlay = el("div", {
position: "fixed", inset: "0", zIndex: OVERLAY_Z_INDEX,
display: "flex", alignItems: "center", justifyContent: "center",
background: "rgba(0,0,0,0.5)", backdropFilter: "blur(2px)",
}, { "data-stack-turnstile-overlay": "true" });
const card = el("div", {
background: "white", borderRadius: "12px", padding: "24px",
maxWidth: "400px", width: "90%", textAlign: "center",
boxShadow: "0 4px 24px rgba(0,0,0,0.18)",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
});
const title = el("p", { margin: "0 0 16px", fontSize: "16px", fontWeight: "600", color: "#333" });
title.textContent = "Please complete the security check";
const widgetContainer = el("div", { display: "flex", justifyContent: "center", minHeight: "65px" });
const errorText = el("p", { margin: "8px 0 0", fontSize: "14px", color: "#dc2626", display: "none" });
const cancelBtn = el("button", {
marginTop: "16px", padding: "8px 20px", border: "1px solid #ddd",
borderRadius: "6px", background: "transparent", cursor: "pointer",
fontSize: "14px", color: "#666",
});
cancelBtn.textContent = "Cancel";
cancelBtn.onmouseover = () => {
cancelBtn.style.background = "#f5f5f5";
};
cancelBtn.onmouseout = () => {
cancelBtn.style.background = "transparent";
};
card.append(title, widgetContainer, errorText, cancelBtn);
overlay.appendChild(card);
document.body.appendChild(overlay);
function cleanup() {
clearTimeout(timeout);
overlay.remove();
if (activeOverlay?.cleanup === cleanup) {
activeOverlay = null;
}
}
activeOverlay = { cleanup, reject };
cancelBtn.onclick = () => {
cleanup();
reject(new BotChallengeUserCancelledError());
};
loadTurnstileScript().then(() => {
const api = getTurnstileApi();
if (!api) {
cleanup();
reject(new StackAssertionError("Turnstile API not available after loadTurnstileScript() resolved"));
return;
}
api.render(widgetContainer, {
sitekey: siteKey,
action,
appearance: "always",
execution: "render",
size: "flexible",
callback: (token) => {
cleanup();
resolve(token);
},
"error-callback": (errorCode) => {
errorText.textContent = errorCode ? `Verification error: ${errorCode}` : "Verification failed. Please try again.";
errorText.style.display = "block";
},
"expired-callback": () => {
errorText.textContent = "Challenge expired. Please solve it again.";
errorText.style.display = "block";
},
});
}).catch((err) => {
cleanup();
reject(err);
});
});
}
// ── Flow orchestrator ──────────────────────────────────────────────────
export type BotChallengeExecuteParams = {
token?: string,
phase?: "invisible" | "visible",
unavailable?: true,
};
export type WithBotChallengeFlowOptions<T> = {
visibleSiteKey: string,
invisibleSiteKey: string,
action: TurnstileAction,
execute: (challenge: BotChallengeExecuteParams) => Promise<T>,
isChallengeRequired: (result: T) => boolean,
};
// We use separate invisible + visible flows (rather than Turnstile's "managed" mode) because:
// 1. Managed mode auto-decides visibility, but we need deterministic server-side logic:
// invisible-fail → require visible challenge → fail = block.
// 2. Invisible + visible use different site keys so the server can tell which phase passed.
// 3. Managed mode doesn't expose an API to programmatically trigger a retry with a
// different widget type, which our two-phase challenge escalation requires.
export async function withBotChallengeFlow<T>(options: WithBotChallengeFlowOptions<T>): Promise<T> {
// Server safe: no Turnstile in SSR — just call execute with no turnstile params
if (typeof window === "undefined") {
return await options.execute({});
}
// Phase 1: invisible token
let invisibleToken: string | undefined;
let usedVisibleFallback = false;
try {
invisibleToken = await executeTurnstileInvisible(options.invisibleSiteKey, options.action);
} catch {
try {
invisibleToken = await showTurnstileVisibleChallenge(options.visibleSiteKey, options.action);
usedVisibleFallback = true;
} catch (e) {
if (e instanceof BotChallengeUserCancelledError) throw e;
// Both challenges failed (for example Cloudflare is unreachable) — tell the
// server explicitly so it can distinguish challenge infra outages from a
// user submitting an invalid visible challenge.
captureError("turnstile-flow-all-challenges-failed", e instanceof Error ? e : new StackAssertionError("Non-Error thrown during Turnstile challenge", { cause: e }));
return await options.execute({ unavailable: true });
}
}
const firstResult = await options.execute({
token: invisibleToken,
phase: invisibleToken ? (usedVisibleFallback ? "visible" : "invisible") : undefined,
});
if (!options.isChallengeRequired(firstResult)) {
return firstResult;
}
// Phase 2: visible challenge (single retry)
let visibleToken: string | undefined;
try {
visibleToken = await showTurnstileVisibleChallenge(options.visibleSiteKey, options.action);
} catch (e) {
if (e instanceof BotChallengeUserCancelledError) throw e;
captureError("turnstile-flow-visible-challenge-failed", e instanceof Error ? e : new StackAssertionError("Non-Error thrown during visible Turnstile challenge", { cause: e }));
throw new BotChallengeExecutionFailedError("Visible bot challenge could not be completed", {
cause: e,
});
}
return await options.execute({ token: visibleToken, phase: "visible" });
}
@@ -0,0 +1,34 @@
export const turnstileActionValues = [
"sign_up_with_credential",
"send_magic_link_email",
"oauth_authenticate",
] as const;
export type TurnstileAction = typeof turnstileActionValues[number];
export const turnstilePhaseValues = [
"invisible",
"visible",
] as const;
export type TurnstilePhase = typeof turnstilePhaseValues[number];
export const turnstileResultValues = [
"ok",
"invalid",
"error",
] as const;
export type TurnstileResult = typeof turnstileResultValues[number];
export const turnstileDevelopmentKeys = {
visibleSiteKey: "1x00000000000000000000AA",
invisibleSiteKey: "1x00000000000000000000BB",
secretKey: "1x0000000000000000000000000000000AA",
forcedChallengeSiteKey: "3x00000000000000000000FF",
} as const;
export function isTurnstileResult(value: unknown): value is TurnstileResult {
return typeof value === "string" && turnstileResultValues.some((status) => status === value);
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { isValidCountryCode, normalizeCountryCode } from "./country-codes";
import { StackAssertionError } from "./errors";
export function getFlagEmoji(twoLetterCountryCode: string) {
if (!/^[a-zA-Z][a-zA-Z]$/.test(twoLetterCountryCode)) throw new StackAssertionError("Country code must be two alphabetical letters");
const codePoints = twoLetterCountryCode
.toUpperCase()
if (!isValidCountryCode(twoLetterCountryCode)) throw new StackAssertionError("Country code must be two alphabetical letters");
const codePoints = normalizeCountryCode(twoLetterCountryCode)
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);