mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
co-authored by
Konstantin Wohlwend
BilalG1
Armaan Jain
nams1570
parent
1e44a8c5ba
commit
e59a70783e
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user