Files
WRNexusJS/packages/captcha
ClintchizandClaude Opus 5 1fb1a8d2d0
Quality / quality (ubuntu-latest) (push) Failing after 12m21s
Quality / quality (windows-latest) (push) Canceled after 0s
chore(release): prepare 0.8.5
Bumps every @wrnexus package 0.8.4 -> 0.8.5 and adds the matching update
migration. The migration is documentation only: moving off <Table> to
<DataTable> and off the @wrnexus/ui main entry to @wrnexus/ui/registry are
source changes no codemod can make safely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:08:05 +05:30
..
2026-07-30 13:36:29 +05:30
2026-08-03 19:47:30 +05:30
2026-08-02 23:18:51 +05:30
2026-08-02 23:18:51 +05:30
2026-07-25 13:38:18 +05:30
2026-08-07 15:08:05 +05:30
2026-08-02 23:18:51 +05:30
2026-07-29 12:51:10 +05:30
2026-07-25 13:38:18 +05:30

@wrnexus/captcha

A first-class CAPTCHA and anti-automation package for WRNexusJS. It supports self-hosted challenges, a managed WRNexus service, external providers, form submission guards, page gates, accessible audio, adaptive risk checks, and a Tailwind-only .wrn component.

Install

bun add @wrnexus/captcha

WRNexusJS automatically discovers the package plugin, component, client runtime, styles, and DevToolbar audit. Use <Captcha /> directly after installation. The browser runtime is injected once only on responses that render a CAPTCHA; no script tag, public-file copy, or manual plugin registration is required. Call captchaPlugin(options) explicitly only when an application needs to override the discovered package configuration.

Included challenge modes

  • Number, alphabet, and alphanumeric image challenges
  • Addition, subtraction, multiplication, and exact-division calculations
  • Generated shape-selection image challenges
  • Audio alternatives for text, numbers, and calculations
  • Honeypot and minimum-completion-time invisible checks
  • Self-hosted “Im not a robot” checkbox challenge with one-time server verification
  • Always, once-per-session, and adaptive page gates
  • Cloudflare Turnstile, Google reCAPTCHA, hCaptcha, managed, and custom providers

Create the self-hosted engine

import {
  createCaptchaEngine,
  createCaptchaHttpHandlers,
  RedisCaptchaStore,
} from "@wrnexus/captcha/server";

const engine = createCaptchaEngine({
  secret: process.env.CAPTCHA_SECRET!,
  store: new RedisCaptchaStore(redis),
  basePath: "/api/captcha",
  challengeTtlMs: 2 * 60_000,
  responseTokenTtlMs: 5 * 60_000,
  maxAttempts: 3,
  minCompletionMs: 800,
});

export const handlers = createCaptchaHttpHandlers(engine);

Mount the handlers from an API catch-all route:

import type { Context } from "@wrnexus/core";
import { handlers } from "../../lib/captcha.ts";

export async function POST(ctx: Context) {
  return (await handlers.handle(ctx.req, ctx)) ?? new Response("Not Found", { status: 404 });
}
export const GET = POST;
export const HEAD = POST;

Use the component

<Captcha
  type="alphanumeric"
  action="signup"
  endpoint="/api/captcha/challenge"
  verifyEndpoint="/api/captcha/verify"
  difficulty="normal"
  disturbance="50"
  imageStyle="random"
  allowedStyles="classic,snow,distortion,wave"
  size="normal"
  showAudio="true"
  showListen="true"
  @success='captchaToken = event.detail.responseToken'
  @failure='formError = event.detail.extra.message'
/>

The component uses Tailwind utilities and --wire-* theme variables. It has no companion component CSS file.

Main props

provider, siteKey, type, action, presentation, difficulty, disturbance, imageStyle, allowedStyles, excludedStyles, randomizeStyle, locale, size, color, class, name, endpoint, verifyEndpoint, responseField, labels/messages, autoLoad, autoVerify, showVerify, showRefresh, showAudio, showListen, showStatus, disabled, required, and the backward-compatible compact alias.

Component sizes

Use one of the three supported display modes:

<Captcha size="compact" action="small-form" />
<Captcha size="normal" action="standard-form" />
<Captcha size="big" action="security-page" />

small/sm are accepted as aliases for compact, while large/lg are accepted as aliases for big. The old compact="true" prop still forces compact mode.

Listen button visibility

Audio remains available by default. Hide the Listen and Use audio controls with either of these props:

<Captcha showListen="false" action="without-listen-button" />
<Captcha showAudio="false" action="without-audio-alternative" />

showListen is the direct UI switch. showAudio remains the broader backward-compatible audio switch.

Im not a robot checkbox

<Captcha
  type="not-robot"
  action="contact-submit"
  size="compact"
  showListen="false"
/>

The checkbox is not a client-only boolean. Clicking it completes a self-hosted invisible challenge that is time-limited, attempt-limited, one-time-use, action-bound, optionally session/hostname/IP-bound, and verified on the server. It is a low-friction anti-automation layer; use adaptive escalation to a visual or external provider for high-risk traffic.

Visual disturbance

Use disturbance for visual and image-selection challenges. It accepts an integer from 25 through 75:

  • 25: light disturbance and easiest readability
  • 50: balanced default
  • 75: maximum supported dots, line crossings, glyph movement, and image-tile noise

The browser sends this value to the challenge API, and the server validates the range before generating the challenge. It is also returned in challenge metadata.

Generated image renderer styles

Text, number, alphanumeric, and calculation CAPTCHA images support 18 concrete renderers plus a random mode:

classic, collision, snow, corrosion, spiderweb, cross-shadow, split, split2, cut, darts, distortion, stitch, striped, wave, grid-noise, scribble, pixel, and broken-lines.

Use a fixed style:

<Captcha
  type="alphanumeric"
  action="signup"
  imageStyle="spiderweb"
  disturbance="55"
/>

Use a new random style whenever the challenge is refreshed:

<Captcha
  type="number"
  action="login"
  imageStyle="random"
  difficulty="normal"
/>

Control the random pool with comma-separated component props or arrays in the TypeScript API:

<Captcha
  type="alphanumeric"
  action="checkout"
  imageStyle="random"
  allowedStyles="classic,snow,distortion,wave"
  excludedStyles="collision"
/>
const challenge = await engine.create({
  action: "checkout",
  type: "alphanumeric",
  imageStyle: "random",
  allowedStyles: ["classic", "snow", "distortion", "wave"],
  excludedStyles: ["collision"],
});

Set randomizeStyle: true to force random selection even when imageStyle names a concrete renderer. The resolved style, requested style, and active pool are returned in challenge metadata. The answer is never embedded in metadata or browser JavaScript.

Events

@ready, @challenge, @input, @verify, @success, @failure, @expired, @refresh, @audioStart, @audioEnd, and @error.

Protect a validated form API

Validate a cloned request first, then consume the CAPTCHA response token. This prevents a valid token from being consumed when ordinary field validation fails.

import { captchaGuard } from "@wrnexus/captcha/server";
import { parseBody } from "@wrnexus/validation";
import contactSchema from "../schemas/contact.ts";
import { engine } from "../lib/captcha.ts";

const guard = captchaGuard({
  action: "contact-submit",
  engine,
  bindHostname: true,
  bindSession: true,
});

export async function POST(ctx) {
  const validation = await parseBody(contactSchema, ctx.req.clone());
  if (!validation.ok) return validation.response;

  return guard(ctx, async () => Response.json({ ok: true, submission: validation.value }));
}

The CAPTCHA runtime binds its required-form check in the capture phase, so a data-schema validator cannot submit the form before CAPTCHA verification. After a successful form request, the component automatically creates a fresh challenge.

Retryable operations such as login

A login may consume a valid CAPTCHA and then fail because the password is incorrect. Configure a short action-bound session grant so the user can correct their credentials without solving CAPTCHA again:

const guard = captchaGuard({
  action: "auth-login",
  engine,
  bindHostname: true,
  bindSession: true,
  verifiedForMs: 5 * 60_000,
});

Keep the verified widget state for non-CAPTCHA form errors:

<Captcha
  action="auth-login"
  required="true"
  resetOnError="false"
/>

The grant is stored in the current session and bound to the configured action. Expired grants and CAPTCHA-specific errors still require and load a fresh challenge. Keep login rate limits and authentication lockout enabled; verifiedForMs removes repeated human verification, not credential-abuse controls.

Validate a schema and CAPTCHA together

const result = await parseWithCaptcha(signupSchema, body, ctx, {
  action: "signup",
  engine,
});

if (!result.ok) return Response.json({ ok: false, errors: result.errors }, { status: 400 });

Page gate

export default captchaPageGate({
  action: "reports-access",
  engine,
  challengePath: "/captcha",
  policy: {
    mode: "session",
    verifiedForMs: 15 * 60_000,
    routeGroups: ["/reports"],
  },
});

Use mode: "always" for every visit, mode: "session" for a temporary grant, or mode: "adaptive" with signals(ctx).

The challenge page should post the return path as a normal hidden field instead of constructing JavaScript inside the HTML action attribute:

<form method="post" action="/api/page-grant">
  <input type="hidden" name="returnTo" value='{returnTo}' />
  <Captcha action="reports-access" />
  <button type="submit">Continue</button>
</form>

The /api/page-grant route reads returnTo, restricts it to the current origin, and redirects only after captchaPageGate() has verified and stored the temporary session grant.

External providers

const turnstile = turnstileProvider({
  secretKey: process.env.TURNSTILE_SECRET!,
  siteKey: process.env.PUBLIC_TURNSTILE_SITE_KEY!,
  expectedHostnames: ["example.com"],
  expectedAction: "signup",
});
<Captcha
  provider="turnstile"
  siteKey="PUBLIC_SITE_KEY"
  action="signup"
/>

Use the matching provider in captchaGuard({ provider: turnstile }). reCAPTCHA and hCaptcha adapters follow the same pattern.

Managed provider

const managed = managedCaptchaProvider({
  baseUrl: "https://captcha.example.com",
  siteKey: process.env.PUBLIC_CAPTCHA_SITE_KEY!,
  secretKey: process.env.CAPTCHA_SECRET_KEY!,
});

For direct browser challenge creation, configure the components endpoint as the managed /v1/challenges URL and its verifyEndpoint as /v1/solve. Keep the secret key only in the server provider.

Stores

  • MemoryCaptchaStore: development and one-process applications
  • SqliteCaptchaStore: adapter for SQLite-like prepare().run/get/all() clients
  • RedisCaptchaStore: shared TTL storage with Lua-backed atomic consumption when eval is available
  • CaptchaStore: implement this interface for PostgreSQL, MySQL, MongoDB, or another backend

Audio

AssetAudioRenderer concatenates bundled English PCM WAV clips without calling an external service. Supply a custom CaptchaAudioRenderer for recorded voices, Hindi or other languages, or managed text-to-speech.

DevToolbar

The automatically discovered CAPTCHA plugin registers its DevToolbar audit panel. It checks for likely client-side secrets, missing action bindings, missing provider site keys, optional CAPTCHA fields, accessible alternatives, and server-verification reminders. Explicit captchaPlugin(options) registration is needed only to override automatic configuration.

Testing

Use deterministic custom generators in unit tests. Never require users or CI to solve random CAPTCHA images. The package includes engine, provider, policy, HTTP, storage, replay, expiry, binding, and audio authorization tests.

Custom challenge generator

import { defineCaptchaGenerator, createCaptchaEngine } from "@wrnexus/captcha";

const wordChallenge = defineCaptchaGenerator({
  type: "word" as const,
  generate(context) {
    const answer = "NEXUS";
    return {
      type: "word",
      presentation: "visual",
      prompt: "Enter the displayed word",
      answer,
      answerKind: "text",
      image: renderYourImage(answer),
      inputMode: "text",
    };
  },
});

const engine = createCaptchaEngine({ secret, generators: [wordChallenge] });

Applications may also implement CaptchaStore, CaptchaAudioRenderer, or use defineCaptchaProvider() for a completely custom service.

Helper and block kit

The package exports captchaTokenFrom, captchaHeaders, captchaFields, verifyCaptcha, verifyCaptchaOrThrow, captchaResultResponse, and captchaContext for consistent server and client integration.

Enable the CAPTCHA plugin to use the low-level <Captcha /> challenge plus complete UI-composed blocks:

  • <CaptchaField />
  • <CaptchaStatus />

CaptchaField composes Card from @wrnexus/ui and keeps the CAPTCHA-specific size separate from the surrounding UI size.