import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { definePlugin } from "@wrnexus/plugin"; import { CAPTCHA_IMAGE_STYLES } from "./challenges/styles.ts"; export interface CaptchaPluginOptions { componentDir?: string; exposeComponentDirectory?: boolean; enableDevToolbar?: boolean; auditExternalProviders?: boolean; } export interface CaptchaAuditIssue { id: string; severity: "error" | "warning" | "suggestion"; title: string; message: string; file: string; } const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); const captchaClientRuntime = join(packageRoot, "assets", "client", "captcha.js"); export function captchaComponentsDir(): string { return join(packageRoot, "components"); } function auditCaptchaSource(code: string, file: string, external: boolean): CaptchaAuditIssue[] { if (!code.includes(" issues.push({ id: `${id}:${file}`, severity, title, message, file }); if (/secret(Key)?\s*=|providerSecret\s*=|captchaSecret\s*=/i.test(code)) { push( "client-secret", "error", "CAPTCHA secret exposed", "Never pass a provider secret or verification secret to a .wrn component.", ); } if (!/action\s*=/.test(code)) { push( "missing-action", "warning", "CAPTCHA action is missing", "Bind each challenge to a stable action such as signup, login, or contact-submit.", ); } if (/required\s*=\s*["']?false/i.test(code)) { push( "optional-captcha", "warning", "CAPTCHA is optional", "Protected forms should require a CAPTCHA response and verify it on the server.", ); } if (!/showAudio\s*=|presentation\s*=\s*["']audio/i.test(code)) { push( "audio-alternative", "suggestion", "Confirm an accessible alternative", "Visual challenges should offer an audio or non-visual alternative.", ); } const imageStyle = code .match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1] ?.trim() .toLowerCase(); if ( imageStyle && !CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number]) ) { push( "unknown-image-style", "error", "Unknown CAPTCHA image style", `Use one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}.`, ); } const disturbance = Number(code.match(/disturbance\s*=\s*["']?(\d+)/i)?.[1] ?? ""); if ( Number.isFinite(disturbance) && disturbance >= 65 && /showAudio\s*=\s*["']?false/i.test(code) ) { push( "hard-without-audio", "warning", "Hard CAPTCHA has no audio alternative", "High disturbance should include audio or another non-visual challenge path.", ); } if ( external && /provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) && !/siteKey\s*=/.test(code) ) { push( "missing-site-key", "error", "Provider site key is missing", "External CAPTCHA providers require a public site key in the browser.", ); } push( "server-verification", "suggestion", "Server verification required", "Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().", ); return issues; } export function captchaPlugin(options: CaptchaPluginOptions = {}) { const metadataKey = "@wrnexus/captcha:audit"; return definePlugin({ name: "@wrnexus/captcha", version: "0.4.0", enforce: "post", componentDirs: options.exposeComponentDirectory === false ? [] : [options.componentDir ?? captchaComponentsDir()], clientRuntimes: [ { id: "captcha", entry: captchaClientRuntime, type: "script", load: "defer", singleton: true, bundle: false, }, ], styleSources: [ { id: "captcha-components", source: options.componentDir ?? captchaComponentsDir(), order: "normal", }, ], configure(config, context) { const current = (config.captcha ?? {}) as Record; config.captcha = { componentDir: options.componentDir ?? captchaComponentsDir(), ...current, }; context.metadata.set( "@wrnexus/captcha:component-dir", options.componentDir ?? captchaComponentsDir(), ); }, transformCode(code, context) { if (context.mode !== "development") return; const previous = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? []; const withoutFile = previous.filter((issue) => issue.file !== context.file); context.metadata.set(metadataKey, [ ...withoutFile, ...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true), ]); }, devToolbarPanels(context) { if (options.enableDevToolbar === false) return []; const issues = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? []; return [ { id: "wrnexus-captcha", title: "CAPTCHA", icon: "shield-check", badge: issues.length, description: "CAPTCHA security, accessibility, and integration checks", issues, }, ]; }, }); } export default captchaPlugin;