import type { Context } from "@wrnexus/core"; import { shouldRequireCaptcha, validCaptchaGrant, type CaptchaSessionGrant } from "./policy.ts"; import { selfHostedProvider } from "./providers/self-hosted.ts"; import type { CaptchaGuardOptions, CaptchaPageGateOptions, CaptchaProvider, CaptchaVerificationResult, } from "./types.ts"; const DEFAULT_FIELD = "wrn-captcha-response"; function resolveAction(value: CaptchaGuardOptions["action"], ctx: Context): string { return typeof value === "function" ? value(ctx) : value; } async function bodyValue(request: Request, field: string): Promise { const header = request.headers.get("x-wrn-captcha-token"); if (header) return header; if (request.method === "GET" || request.method === "HEAD") return undefined; const clone = request.clone(); const contentType = clone.headers.get("content-type") ?? ""; try { if (contentType.includes("application/json")) { const body = (await clone.json()) as Record; const value = body[field] ?? body.captchaToken ?? body.responseToken; return value === undefined ? undefined : String(value); } if (contentType.includes("form")) { const form = await clone.formData(); const value = form.get(field); return typeof value === "string" ? value : undefined; } } catch { return undefined; } return undefined; } function providerFor(options: CaptchaGuardOptions): CaptchaProvider { if (options.provider) return options.provider; if (options.engine) return selfHostedProvider(options.engine); throw new TypeError("captchaGuard requires provider or engine"); } async function verifyRequest( ctx: Context, options: CaptchaGuardOptions, ): Promise { const provider = providerFor(options); const action = resolveAction(options.action, ctx); const field = options.responseField ?? provider.client.responseField ?? DEFAULT_FIELD; const token = await bodyValue(ctx.req, field); return provider.verify({ action, providerToken: token, responseToken: token, hostname: options.bindHostname === false ? undefined : ctx.url.hostname, sessionId: options.bindSession === false ? undefined : ctx.session.id(), ip: options.bindIp ? ctx.ip : undefined, }); } function defaultFailure(options: CaptchaGuardOptions, result: CaptchaVerificationResult): Response { return new Response(options.failureMessage ?? result.message ?? "CAPTCHA verification failed", { status: options.failureStatus ?? 403, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-wrn-captcha-error": result.code ?? "verification-failed", }, }); } export function captchaGuard(options: CaptchaGuardOptions) { const verifiedForMs = options.verifiedForMs ?? 0; if (!Number.isFinite(verifiedForMs) || verifiedForMs < 0) { throw new TypeError("captchaGuard verifiedForMs must be a non-negative finite duration"); } const sessionKey = options.sessionKey ?? "wrnexus.captcha.grants"; return async (ctx: Context, next: () => Promise | Response): Promise => { const action = resolveAction(options.action, ctx); const now = Date.now(); const grants = ctx.session.get(sessionKey) ?? []; const grant = verifiedForMs > 0 ? validCaptchaGrant(grants, action, now) : undefined; if (grant) { ctx.locals.captcha = { success: true, provider: grant.provider, action, }; ctx.locals.captchaVerified = true; return next(); } const result = await verifyRequest(ctx, options); ctx.locals.captcha = result; if (!result.success) return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result); if (verifiedForMs > 0) { const fresh: CaptchaSessionGrant = { action, provider: result.provider, expiresAt: now + verifiedForMs, }; ctx.session.set(sessionKey, [ ...grants.filter((item) => item.expiresAt > now && item.action !== action), fresh, ]); } return next(); }; } export function captchaPageGate(options: CaptchaPageGateOptions) { const sessionKey = options.sessionKey ?? "wrnexus.captcha.grants"; const returnToParam = options.returnToParam ?? "returnTo"; const challengePath = options.challengePath ?? "/captcha"; const policy = options.policy ?? { mode: "session" as const }; return async (ctx: Context, next: () => Promise | Response): Promise => { const action = resolveAction(options.action, ctx); const now = Date.now(); const routeGroup = policy.routeGroups?.find((group) => ctx.url.pathname.startsWith(group)); const grants = ctx.session.get(sessionKey) ?? []; if (validCaptchaGrant(grants, action, now, routeGroup)) return next(); const signals = (await options.signals?.(ctx)) ?? {}; const decision = shouldRequireCaptcha(action, policy, signals); ctx.locals.captchaRisk = decision; if (!decision.challenge) return next(); const token = await bodyValue(ctx.req, options.responseField ?? DEFAULT_FIELD); if (token) { const result = await verifyRequest(ctx, options); ctx.locals.captcha = result; if (result.success) { const grant: CaptchaSessionGrant = { action, routeGroup, provider: result.provider, expiresAt: now + (policy.verifiedForMs ?? 15 * 60_000), }; ctx.session.set(sessionKey, [...grants.filter((item) => item.expiresAt > now), grant]); return next(); } if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") { return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result); } } const redirect = new URL(challengePath, ctx.url); redirect.searchParams.set(returnToParam, `${ctx.url.pathname}${ctx.url.search}`); redirect.searchParams.set("action", action); return Response.redirect(redirect, 302); }; } export function clearCaptchaGrants(ctx: Context, sessionKey = "wrnexus.captcha.grants"): void { ctx.session.delete(sessionKey); }