import type { CaptchaProvider, CaptchaProviderClientConfig, CaptchaProviderName, CaptchaVerificationResult, VerifyCaptchaInput, } from "../types.ts"; export interface SiteverifyProviderOptions { secretKey: string; siteKey?: string; expectedHostnames?: string[]; expectedAction?: string; minScore?: number; timeoutMs?: number; fetch?: typeof fetch; } export interface SiteverifyPreset { name: CaptchaProviderName; endpoint: string; client: CaptchaProviderClientConfig; sendSiteKey?: boolean; scoreDirection?: "higher-is-human" | "higher-is-risk"; } interface SiteverifyPayload { success?: boolean; hostname?: string; action?: string; score?: number; challenge_ts?: string; "error-codes"?: string[]; error_codes?: string[]; [key: string]: unknown; } function providerFailure( name: CaptchaProviderName, action: string, code: string, message: string, metadata?: Record, ): CaptchaVerificationResult { return { success: false, provider: name, action, code, message, metadata }; } export class SiteverifyCaptchaProvider implements CaptchaProvider { readonly name: CaptchaProviderName; readonly client: CaptchaProviderClientConfig; constructor( private readonly preset: SiteverifyPreset, private readonly options: SiteverifyProviderOptions, ) { if (!options.secretKey) throw new TypeError(`${preset.name} secretKey is required`); this.name = preset.name; this.client = { ...preset.client, siteKey: options.siteKey }; } async verify(input: VerifyCaptchaInput): Promise { const token = input.providerToken ?? input.responseToken; const action = input.action; if (!token) return providerFailure(this.name, action, "missing-input", "Missing provider response token"); if (token.length > 4096) return providerFailure(this.name, action, "invalid-input", "Provider token is too long"); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000); const body = new URLSearchParams({ secret: this.options.secretKey, response: token }); if (input.ip) body.set("remoteip", input.ip); if (this.preset.sendSiteKey && this.options.siteKey) body.set("sitekey", this.options.siteKey); try { const response = await (this.options.fetch ?? fetch)(this.preset.endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", }, body, signal: controller.signal, }); if (!response.ok) { return providerFailure( this.name, action, "provider-error", `${this.name} verification returned HTTP ${response.status}`, ); } const data = (await response.json()) as SiteverifyPayload; if (!data.success) { const codes = data["error-codes"] ?? data.error_codes ?? []; return providerFailure( this.name, action, codes.includes("timeout-or-duplicate") || codes.includes("already-seen-response") ? "already-used" : codes.includes("expired-input-response") ? "expired" : "invalid-input", "Provider verification failed", { errorCodes: codes }, ); } const expectedAction = this.options.expectedAction ?? action; if (data.action && expectedAction && data.action !== expectedAction) { return providerFailure( this.name, action, "action-mismatch", "Provider action does not match", { receivedAction: data.action, }, ); } if ( this.options.expectedHostnames?.length && (!data.hostname || !this.options.expectedHostnames.includes(data.hostname)) ) { return providerFailure( this.name, action, "hostname-mismatch", "Provider hostname does not match", { hostname: data.hostname, }, ); } if (this.options.minScore !== undefined && typeof data.score === "number") { const rejected = this.preset.scoreDirection === "higher-is-risk" ? data.score >= this.options.minScore : data.score < this.options.minScore; if (rejected) { return providerFailure( this.name, action, "risk-rejected", "Provider risk score did not pass", { score: data.score, }, ); } } return { success: true, provider: this.name, action, score: data.score, hostname: data.hostname, metadata: { challengeTimestamp: data.challenge_ts, raw: data, }, }; } catch (error) { return providerFailure( this.name, action, error instanceof DOMException && error.name === "AbortError" ? "network-error" : "provider-error", error instanceof DOMException && error.name === "AbortError" ? `${this.name} verification timed out` : `${this.name} verification failed`, ); } finally { clearTimeout(timeout); } } }