stack/packages/stack-shared/src/utils/turnstile-browser.ts
Mantra e59a70783e
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 <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>
2026-03-20 21:26:45 +00:00

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;
}