import { env } from "cloudflare:workers";
import { forbidden } from "./errors";
import { requestIp } from "./limits";
const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const MAX_TOKEN_LENGTH = 2048;
const VERIFY_TIMEOUT_MS = 10_000;
export type TurnstileAction = "signup" | "login";
type SiteverifyResult = { success?: boolean; action?: string; hostname?: string };
/** The public sitekey; Turnstile is on for a deployment exactly when this is set. */
export function turnstileSiteKey(): string | null {
return env.TURNSTILE_SITEKEY?.trim() || null;
}
/** TURNSTILE_HOSTNAMES, or this deployment's PUBLIC_URL host, so a production forge never accepts localhost tokens. */
function expectedHostnames(): Set<string> {
const configured = (env.TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((hostname) => hostname.trim().toLowerCase())
.filter(Boolean);
if (configured.length > 0) return new Set(configured);
try {
return new Set([new URL(env.PUBLIC_URL).hostname.toLowerCase()]);
} catch {
return new Set();
}
}
/**
* Canonical server-side Siteverify. Fails closed: a missing secret, an unreachable Siteverify, a wrong
* action or a token minted for another hostname all reject. Tokens are single-use, so a replay fails too.
*/
export async function requireTurnstile(token: string | undefined, action: TurnstileAction): Promise<void> {
if (!turnstileSiteKey()) return;
const secret = env.TURNSTILE_SECRET;
const hostnames = expectedHostnames();
if (!secret || hostnames.size === 0) {