mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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 <n2d4xc@gmail.com> Co-authored-by: BilalG1 <bg2002@gmail.com> Co-authored-by: Armaan Jain <84474476+Developing-Gamer@users.noreply.github.com> Co-authored-by: nams1570 <amanganapathy@gmail.com>
107 lines
3.4 KiB
TypeScript
107 lines
3.4 KiB
TypeScript
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;
|
|
}
|