release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
+8 -14
View File
@@ -126,10 +126,7 @@ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
try {
const packageEntry = createRequire(import.meta.url).resolve("@wrnexus/captcha/audio");
addCandidate(
candidates,
join(dirname(dirname(dirname(packageEntry))), "assets", "audio"),
);
addCandidate(candidates, join(dirname(dirname(dirname(packageEntry))), "assets", "audio"));
} catch {
// The source-relative and cwd fallbacks below still support direct source use.
}
@@ -140,7 +137,10 @@ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
let current = process.cwd();
for (let depth = 0; depth < 8; depth += 1) {
addCandidate(candidates, join(current, "packages", "captcha", "assets", "audio"));
addCandidate(candidates, join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"));
addCandidate(
candidates,
join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"),
);
const parent = dirname(current);
if (parent === current) break;
@@ -190,9 +190,7 @@ export class AssetAudioRenderer implements CaptchaAudioRenderer {
}
if (!sequence.length) throw new Error("Cannot render an empty CAPTCHA audio sequence");
const clips = await Promise.all(
sequence.map((token) => this.load(language, safeToken(token))),
);
const clips = await Promise.all(sequence.map((token) => this.load(language, safeToken(token))));
const first = clips[0]!;
for (const clip of clips) {
@@ -223,17 +221,13 @@ export class AssetAudioRenderer implements CaptchaAudioRenderer {
const cached = this.cache.get(key);
if (cached) return cached;
const bytes = new Uint8Array(
await readFile(join(this.assetsDir, language, `${token}.wav`)),
);
const bytes = new Uint8Array(await readFile(join(this.assetsDir, language, `${token}.wav`)));
const parsed = parseWav(bytes);
this.cache.set(key, parsed);
return parsed;
}
}
export function createAssetAudioRenderer(
options?: AssetAudioRendererOptions,
): AssetAudioRenderer {
export function createAssetAudioRenderer(options?: AssetAudioRendererOptions): AssetAudioRenderer {
return new AssetAudioRenderer(options);
}
@@ -51,7 +51,13 @@ export class CalculationCaptchaGenerator implements CaptchaChallengeGenerator {
answerKind: "text",
image: renderTextChallenge(expression, context),
inputMode: "numeric",
audioSequence: ["what", "is", ...numberTokens(left), ...(OPERATOR_WORDS[operator] ?? []), ...numberTokens(right)],
audioSequence: [
"what",
"is",
...numberTokens(left),
...(OPERATOR_WORDS[operator] ?? []),
...numberTokens(right),
],
metadata: { operator, imageStyle: context.imageStyle },
};
}
+45 -11
View File
@@ -4,7 +4,15 @@ import type {
CaptchaImageItem,
GeneratedCaptchaChallenge,
} from "../types.ts";
import { createImage, drawLine, fillCircle, fillPolygon, fillRect, pngDataUri, setPixel } from "./png.ts";
import {
createImage,
drawLine,
fillCircle,
fillPolygon,
fillRect,
pngDataUri,
setPixel,
} from "./png.ts";
const SHAPES = ["circle", "square", "triangle", "diamond", "star"] as const;
type Shape = (typeof SHAPES)[number];
@@ -39,9 +47,26 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
if (shape === "circle") fillCircle(image, cx, cy, size, color);
else if (shape === "square") fillRect(image, cx - size, cy - size, size * 2, size * 2, color);
else if (shape === "triangle") {
fillPolygon(image, [[cx, cy - size], [cx - size, cy + size], [cx + size, cy + size]], color);
fillPolygon(
image,
[
[cx, cy - size],
[cx - size, cy + size],
[cx + size, cy + size],
],
color,
);
} else if (shape === "diamond") {
fillPolygon(image, [[cx, cy - size], [cx - size, cy], [cx, cy + size], [cx + size, cy]], color);
fillPolygon(
image,
[
[cx, cy - size],
[cx - size, cy],
[cx, cy + size],
[cx + size, cy],
],
color,
);
} else {
fillPolygon(image, starPoints(cx, cy, size, size * 0.45), color);
}
@@ -49,12 +74,12 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
const ratio = disturbanceRatio(context);
const dots = Math.round(35 + ratio * 150);
for (let index = 0; index < dots; index++) {
setPixel(
image,
context.randomInt(0, 95),
context.randomInt(0, 71),
[context.randomInt(105, 225), context.randomInt(105, 225), context.randomInt(105, 225), Math.round(55 + ratio * 65)],
);
setPixel(image, context.randomInt(0, 95), context.randomInt(0, 71), [
context.randomInt(105, 225),
context.randomInt(105, 225),
context.randomInt(105, 225),
Math.round(55 + ratio * 65),
]);
}
const lines = Math.round(1 + ratio * 4);
@@ -65,7 +90,12 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
context.randomInt(0, 71),
context.randomInt(0, 95),
context.randomInt(0, 71),
[context.randomInt(100, 210), context.randomInt(100, 210), context.randomInt(100, 210), Math.round(45 + ratio * 55)],
[
context.randomInt(100, 210),
context.randomInt(100, 210),
context.randomInt(100, 210),
Math.round(45 + ratio * 55),
],
ratio > 0.75 ? 2 : 1,
);
}
@@ -105,7 +135,11 @@ export class ImageCaptchaGenerator implements CaptchaChallengeGenerator {
}
shuffle(entries, context);
const answer = entries.filter((entry) => entry.shape === target).map((entry) => entry.id).sort().join(",");
const answer = entries
.filter((entry) => entry.shape === target)
.map((entry) => entry.id)
.sort()
.join(",");
return {
type: "image",
presentation: "visual",
+12 -3
View File
@@ -1,8 +1,16 @@
import type { CaptchaChallengeGenerator } from "../types.ts";
import { alphaCaptchaGenerator, alphanumericCaptchaGenerator, numberCaptchaGenerator } from "./text.ts";
import {
alphaCaptchaGenerator,
alphanumericCaptchaGenerator,
numberCaptchaGenerator,
} from "./text.ts";
import { calculationCaptchaGenerator } from "./calculation.ts";
import { imageCaptchaGenerator } from "./image.ts";
import { honeypotCaptchaGenerator, notRobotCaptchaGenerator, timingCaptchaGenerator } from "./invisible.ts";
import {
honeypotCaptchaGenerator,
notRobotCaptchaGenerator,
timingCaptchaGenerator,
} from "./invisible.ts";
export * from "./text.ts";
export * from "./calculation.ts";
@@ -13,7 +21,8 @@ export * from "./styles.ts";
export function defineCaptchaGenerator<T extends CaptchaChallengeGenerator>(generator: T): T {
if (!generator.type) throw new TypeError("CAPTCHA generator requires a stable type");
if (typeof generator.generate !== "function") throw new TypeError("CAPTCHA generator requires generate()");
if (typeof generator.generate !== "function")
throw new TypeError("CAPTCHA generator requires generate()");
return generator;
}
+2 -1
View File
@@ -19,7 +19,8 @@ export class InvisibleCaptchaGenerator implements CaptchaChallengeGenerator {
return {
type: this.type,
presentation: "invisible",
prompt: this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
prompt:
this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
answer: JSON.stringify({ honeypot: "", timingToken }),
answerKind: "invisible",
inputMode: "none",
+72 -12
View File
@@ -8,7 +8,11 @@ export interface RgbaImage {
export type Rgba = readonly [number, number, number, number?];
export function createImage(width: number, height: number, background: Rgba = [255, 255, 255, 255]): RgbaImage {
export function createImage(
width: number,
height: number,
background: Rgba = [255, 255, 255, 255],
): RgbaImage {
const data = new Uint8Array(width * height * 4);
const alpha = background[3] ?? 255;
for (let index = 0; index < data.length; index += 4) {
@@ -33,13 +37,28 @@ export function setPixel(image: RgbaImage, x: number, y: number, color: Rgba): v
image.data[index + 3] = 255;
}
export function fillRect(image: RgbaImage, x: number, y: number, width: number, height: number, color: Rgba): void {
export function fillRect(
image: RgbaImage,
x: number,
y: number,
width: number,
height: number,
color: Rgba,
): void {
for (let py = Math.floor(y); py < Math.ceil(y + height); py++) {
for (let px = Math.floor(x); px < Math.ceil(x + width); px++) setPixel(image, px, py, color);
}
}
export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y1: number, color: Rgba, thickness = 1): void {
export function drawLine(
image: RgbaImage,
x0: number,
y0: number,
x1: number,
y1: number,
color: Rgba,
thickness = 1,
): void {
let x = Math.round(x0);
let y = Math.round(y0);
const targetX = Math.round(x1);
@@ -50,7 +69,14 @@ export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y
const sy = y < targetY ? 1 : -1;
let error = dx + dy;
while (true) {
fillRect(image, x - Math.floor(thickness / 2), y - Math.floor(thickness / 2), thickness, thickness, color);
fillRect(
image,
x - Math.floor(thickness / 2),
y - Math.floor(thickness / 2),
thickness,
thickness,
color,
);
if (x === targetX && y === targetY) break;
const twice = 2 * error;
if (twice >= dy) {
@@ -64,7 +90,13 @@ export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y
}
}
export function fillCircle(image: RgbaImage, centerX: number, centerY: number, radius: number, color: Rgba): void {
export function fillCircle(
image: RgbaImage,
centerX: number,
centerY: number,
radius: number,
color: Rgba,
): void {
const r2 = radius * radius;
for (let y = Math.floor(centerY - radius); y <= Math.ceil(centerY + radius); y++) {
for (let x = Math.floor(centerX - radius); x <= Math.ceil(centerX + radius); x++) {
@@ -75,7 +107,11 @@ export function fillCircle(image: RgbaImage, centerX: number, centerY: number, r
}
}
function pointInPolygon(x: number, y: number, points: readonly (readonly [number, number])[]): boolean {
function pointInPolygon(
x: number,
y: number,
points: readonly (readonly [number, number])[],
): boolean {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i]![0];
@@ -88,13 +124,18 @@ function pointInPolygon(x: number, y: number, points: readonly (readonly [number
return inside;
}
export function fillPolygon(image: RgbaImage, points: readonly (readonly [number, number])[], color: Rgba): void {
export function fillPolygon(
image: RgbaImage,
points: readonly (readonly [number, number])[],
color: Rgba,
): void {
const minX = Math.floor(Math.min(...points.map((point) => point[0])));
const maxX = Math.ceil(Math.max(...points.map((point) => point[0])));
const minY = Math.floor(Math.min(...points.map((point) => point[1])));
const maxY = Math.ceil(Math.max(...points.map((point) => point[1])));
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
for (let x = minX; x <= maxX; x++)
if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
}
}
@@ -134,7 +175,15 @@ export function drawText(
let cursor = options.x;
for (let index = 0; index < text.length; index++) {
const jitter = options.jitter?.(index) ?? { x: 0, y: 0, shear: 0 };
drawGlyph(image, text[index]!, cursor + jitter.x, options.y + jitter.y, options.scale, options.color, jitter.shear);
drawGlyph(
image,
text[index]!,
cursor + jitter.x,
options.y + jitter.y,
options.scale,
options.color,
jitter.shear,
);
cursor += options.scale * 5 + spacing;
}
}
@@ -170,7 +219,12 @@ function adler32(bytes: Uint8Array): number {
}
function u32(value: number): Uint8Array {
return Uint8Array.of((value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255);
return Uint8Array.of(
(value >>> 24) & 255,
(value >>> 16) & 255,
(value >>> 8) & 255,
value & 255,
);
}
function concat(parts: readonly Uint8Array[]): Uint8Array {
@@ -195,9 +249,15 @@ function deflateStored(data: Uint8Array): Uint8Array {
const size = Math.min(65535, data.length - offset);
const final = offset + size >= data.length;
const length = size;
const inverse = (~length) & 0xffff;
const inverse = ~length & 0xffff;
blocks.push(
Uint8Array.of(final ? 1 : 0, length & 255, (length >>> 8) & 255, inverse & 255, (inverse >>> 8) & 255),
Uint8Array.of(
final ? 1 : 0,
length & 255,
(length >>> 8) & 255,
inverse & 255,
(inverse >>> 8) & 255,
),
data.slice(offset, offset + size),
);
}
+6 -20
View File
@@ -1,7 +1,4 @@
import type {
CaptchaConcreteImageStyle,
CaptchaImageStyle,
} from "../types.ts";
import type { CaptchaConcreteImageStyle, CaptchaImageStyle } from "../types.ts";
export const CAPTCHA_CONCRETE_IMAGE_STYLES = [
"classic",
@@ -52,9 +49,7 @@ export function normalizeCaptchaImageStyle(
if (value === undefined || value === null || value === "") return fallback;
const normalized = String(value).trim().toLowerCase();
if (!STYLE_SET.has(normalized)) {
throw new RangeError(
`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`,
);
throw new RangeError(`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`);
}
return normalized as CaptchaImageStyle;
}
@@ -67,9 +62,7 @@ export function normalizeCaptchaImageStyleList(
for (const entry of styleValues(value)) {
if (entry === "random") continue;
if (!CONCRETE_STYLE_SET.has(entry)) {
throw new RangeError(
`${name} contains an unknown image style: ${entry}`,
);
throw new RangeError(`${name} contains an unknown image style: ${entry}`);
}
const style = entry as CaptchaConcreteImageStyle;
if (!styles.includes(style)) styles.push(style);
@@ -95,17 +88,12 @@ export function resolveCaptchaImageStyle(
options: ResolveCaptchaImageStyleOptions,
): ResolvedCaptchaImageStyle {
const requested = normalizeCaptchaImageStyle(options.imageStyle, "random");
const allowed = normalizeCaptchaImageStyleList(
options.allowedStyles,
"allowedStyles",
);
const allowed = normalizeCaptchaImageStyleList(options.allowedStyles, "allowedStyles");
const excluded = new Set(
normalizeCaptchaImageStyleList(options.excludedStyles, "excludedStyles"),
);
const source = allowed.length
? allowed
: [...CAPTCHA_CONCRETE_IMAGE_STYLES];
const source = allowed.length ? allowed : [...CAPTCHA_CONCRETE_IMAGE_STYLES];
const pool = source.filter((style) => !excluded.has(style));
if (!pool.length) {
@@ -116,9 +104,7 @@ export function resolveCaptchaImageStyle(
if (!options.randomizeStyle && requested !== "random") {
if (!pool.includes(requested)) {
throw new RangeError(
`imageStyle ${requested} is not available in the configured style pool`,
);
throw new RangeError(`imageStyle ${requested} is not available in the configured style pool`);
}
return { requested, resolved: requested, pool };
}
+9 -3
View File
@@ -11,7 +11,9 @@ const ALPHA = "ABCDEFGHJKMNPQRSTUVWXYZ";
const ALPHANUMERIC = `${ALPHA}${NUMBERS}`;
function defaultLength(context: CaptchaGeneratorContext): number {
return context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6);
return (
context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6)
);
}
function charset(type: CaptchaChallengeType): string {
@@ -20,11 +22,15 @@ function charset(type: CaptchaChallengeType): string {
return ALPHANUMERIC;
}
function generateText(type: "number" | "alpha" | "alphanumeric", context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
function generateText(
type: "number" | "alpha" | "alphanumeric",
context: CaptchaGeneratorContext,
): GeneratedCaptchaChallenge {
const source = charset(type);
const length = Math.max(3, Math.min(10, defaultLength(context)));
let answer = "";
for (let index = 0; index < length; index++) answer += source[context.randomInt(0, source.length - 1)];
for (let index = 0; index < length; index++)
answer += source[context.randomInt(0, source.length - 1)];
return {
type,
presentation: "visual",
+139 -42
View File
@@ -1,7 +1,4 @@
import type {
CaptchaConcreteImageStyle,
CaptchaGeneratorContext,
} from "../types.ts";
import type { CaptchaConcreteImageStyle, CaptchaGeneratorContext } from "../types.ts";
import {
createImage,
drawGlyph,
@@ -56,7 +53,11 @@ function randomColor(
];
}
function rawPixel(image: RgbaImage, x: number, y: number): readonly [number, number, number, number] {
function rawPixel(
image: RgbaImage,
x: number,
y: number,
): readonly [number, number, number, number] {
const px = clamp(Math.round(x), 0, image.width - 1);
const py = clamp(Math.round(y), 0, image.height - 1);
const index = (py * image.width + px) * 4;
@@ -98,11 +99,12 @@ function layoutFor(text: string, style: CaptchaConcreteImageStyle): TextLayout {
const scale = text.length > 9 ? 3 : text.length > 7 ? 4 : 5;
const glyphWidth = scale * 5;
const normalSpacing = scale + 3;
const spacing = style === "collision"
? Math.max(-Math.floor(scale * 0.45), -2)
: style === "cross-shadow"
? scale
: normalSpacing;
const spacing =
style === "collision"
? Math.max(-Math.floor(scale * 0.45), -2)
: style === "cross-shadow"
? scale
: normalSpacing;
const textWidth = text.length * glyphWidth + Math.max(0, text.length - 1) * spacing;
const textHeight = scale * 7;
return {
@@ -218,9 +220,8 @@ function drawCharacters(
const y = layout.startY + context.randomInt(-jitterY, jitterY);
const glyphShear = (context.randomFloat() - 0.5) * shear;
const accent = ACCENTS[context.randomInt(0, ACCENTS.length - 1)]!;
const color = style === "collision" || style === "cross-shadow" || style === "pixel"
? accent
: INK;
const color =
style === "collision" || style === "cross-shadow" || style === "pixel" ? accent : INK;
if (style === "cross-shadow") {
drawGlyph(image, character, x - 3, y + 3, layout.scale, [37, 99, 235, 95], glyphShear);
@@ -228,7 +229,15 @@ function drawCharacters(
} else if (style === "collision") {
drawGlyph(image, character, x - 2, y + 2, layout.scale, [15, 23, 42, 75], glyphShear);
if (index > 0 && context.randomFloat() < 0.65) {
drawGlyph(image, character, x + context.randomInt(-4, 1), y, layout.scale, [2, 6, 23, 60], -glyphShear);
drawGlyph(
image,
character,
x + context.randomInt(-4, 1),
y,
layout.scale,
[2, 6, 23, 60],
-glyphShear,
);
}
} else if (context.randomFloat() < 0.28 + ratio * 0.35) {
drawGlyph(image, character, x + 1, y + 1, layout.scale, [15, 23, 42, 70], glyphShear);
@@ -283,13 +292,25 @@ function shiftColumns(
function applyClassic(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
addCrossingLines(image, context, Math.round(3 + ratio * 5), Math.round(55 + ratio * 55), ratio > 0.7 ? 2 : 1);
addCrossingLines(
image,
context,
Math.round(3 + ratio * 5),
Math.round(55 + ratio * 55),
ratio > 0.7 ? 2 : 1,
);
addDots(image, context, Math.round(140 + ratio * 470), Math.round(45 + ratio * 50));
}
function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
addCrossingLines(image, context, Math.round(5 + ratio * 7), Math.round(60 + ratio * 60), ratio > 0.55 ? 2 : 1);
addCrossingLines(
image,
context,
Math.round(5 + ratio * 7),
Math.round(60 + ratio * 60),
ratio > 0.55 ? 2 : 1,
);
addDots(image, context, Math.round(120 + ratio * 320), 70);
const bars = Math.round(2 + ratio * 5);
for (let index = 0; index < bars; index++) {
@@ -306,7 +327,15 @@ function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): voi
function applySnow(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
addDots(image, context, Math.round(420 + ratio * 950), Math.round(85 + ratio * 75), 120, 245, ratio > 0.5 ? 2 : 1);
addDots(
image,
context,
Math.round(420 + ratio * 950),
Math.round(85 + ratio * 75),
120,
245,
ratio > 0.5 ? 2 : 1,
);
for (let index = 0; index < Math.round(35 + ratio * 90); index++) {
const x = context.randomInt(0, image.width - 1);
const y = context.randomInt(0, image.height - 1);
@@ -327,7 +356,14 @@ function applyCorrosion(
const x = context.randomInt(layout.startX - 5, layout.startX + layout.textWidth + 5);
const y = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
const size = context.randomInt(1, ratio > 0.65 ? 4 : 3);
fillRect(image, x, y, size, size, context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210]);
fillRect(
image,
x,
y,
size,
size,
context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210],
);
}
addDots(image, context, Math.round(170 + ratio * 430), 85, 90, 190, 2);
addCrossingLines(image, context, Math.round(2 + ratio * 4), 60, 1);
@@ -341,12 +377,32 @@ function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): voi
}));
for (let index = 0; index < nodes.length; index++) {
const current = nodes[index]!;
const next = nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
drawLine(image, current.x, current.y, next.x, next.y, [71, 85, 105, Math.round(65 + ratio * 65)], 1);
if (index % 2 === 0) drawCircleOutline(image, current.x, current.y, context.randomInt(2, 5), [100, 116, 139, 70], 1);
const next =
nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
drawLine(
image,
current.x,
current.y,
next.x,
next.y,
[71, 85, 105, Math.round(65 + ratio * 65)],
1,
);
if (index % 2 === 0)
drawCircleOutline(
image,
current.x,
current.y,
context.randomInt(2, 5),
[100, 116, 139, 70],
1,
);
}
const anchorX = context.randomInt(Math.floor(image.width * 0.25), Math.floor(image.width * 0.75));
const anchorY = context.randomInt(Math.floor(image.height * 0.25), Math.floor(image.height * 0.75));
const anchorY = context.randomInt(
Math.floor(image.height * 0.25),
Math.floor(image.height * 0.75),
);
for (let index = 0; index < Math.round(7 + ratio * 7); index++) {
const angle = (index / Math.round(7 + ratio * 7)) * Math.PI * 2;
drawLine(
@@ -365,8 +421,24 @@ function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): voi
function applyCrossShadow(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const centerY = Math.floor(image.height / 2);
drawLine(image, 0, centerY - 6, image.width - 1, centerY + 6, [37, 99, 235, 75], ratio > 0.55 ? 2 : 1);
drawLine(image, 0, centerY + 7, image.width - 1, centerY - 8, [220, 38, 38, 65], ratio > 0.55 ? 2 : 1);
drawLine(
image,
0,
centerY - 6,
image.width - 1,
centerY + 6,
[37, 99, 235, 75],
ratio > 0.55 ? 2 : 1,
);
drawLine(
image,
0,
centerY + 7,
image.width - 1,
centerY - 8,
[220, 38, 38, 65],
ratio > 0.55 ? 2 : 1,
);
addCrossingLines(image, context, Math.round(2 + ratio * 5), 55, 1);
addDots(image, context, Math.round(120 + ratio * 300), 60);
}
@@ -399,11 +471,7 @@ function applySplit2(image: RgbaImage, context: CaptchaGeneratorContext): void {
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
}
function applyCut(
image: RgbaImage,
context: CaptchaGeneratorContext,
layout: TextLayout,
): void {
function applyCut(image: RgbaImage, context: CaptchaGeneratorContext, layout: TextLayout): void {
const ratio = disturbanceRatio(context);
const cuts = Math.round(4 + ratio * 8);
for (let index = 0; index < cuts; index++) {
@@ -459,12 +527,32 @@ function applyStitch(image: RgbaImage, context: CaptchaGeneratorContext): void {
const rows = Math.round(4 + ratio * 5);
for (let index = 0; index < rows; index++) {
const y = Math.round(((index + 1) / (rows + 1)) * image.height);
drawDashedLine(image, 0, y, image.width - 1, y + context.randomInt(-3, 3), [71, 85, 105, 75], 4, 4, 1);
drawDashedLine(
image,
0,
y,
image.width - 1,
y + context.randomInt(-3, 3),
[71, 85, 105, 75],
4,
4,
1,
);
}
const columns = Math.round(2 + ratio * 4);
for (let index = 0; index < columns; index++) {
const x = context.randomInt(10, image.width - 10);
drawDashedLine(image, x, 0, x + context.randomInt(-5, 5), image.height - 1, [100, 116, 139, 65], 3, 5, 1);
drawDashedLine(
image,
x,
0,
x + context.randomInt(-5, 5),
image.height - 1,
[100, 116, 139, 65],
3,
5,
1,
);
}
for (let index = 0; index < Math.round(18 + ratio * 35); index++) {
const x = context.randomInt(0, image.width - 1);
@@ -479,7 +567,15 @@ function applyStriped(image: RgbaImage, context: CaptchaGeneratorContext): void
const spacing = Math.round(10 - ratio * 4);
const offset = context.randomInt(-image.height, image.width);
for (let x = offset; x < image.width + image.height; x += spacing) {
drawLine(image, x, 0, x - image.height, image.height - 1, [71, 85, 105, Math.round(45 + ratio * 45)], ratio > 0.7 ? 2 : 1);
drawLine(
image,
x,
0,
x - image.height,
image.height - 1,
[71, 85, 105, Math.round(45 + ratio * 45)],
ratio > 0.7 ? 2 : 1,
);
}
for (let y = context.randomInt(3, 9); y < image.height; y += context.randomInt(8, 14)) {
drawLine(image, 0, y, image.width - 1, y, [148, 163, 184, 45], 1);
@@ -595,16 +691,20 @@ function applyBrokenLines(
for (let index = 0; index < Math.round(12 + ratio * 28); index++) {
const x = context.randomInt(0, image.width - 12);
const y = context.randomInt(0, image.height - 1);
drawLine(image, x, y, x + context.randomInt(4, 18), y + context.randomInt(-2, 2), randomColor(context, 45, 175, 75), 1);
drawLine(
image,
x,
y,
x + context.randomInt(4, 18),
y + context.randomInt(-2, 2),
randomColor(context, 45, 175, 75),
1,
);
}
addDots(image, context, Math.round(70 + ratio * 180), 60);
}
function applyStyle(
image: RgbaImage,
context: CaptchaGeneratorContext,
layout: TextLayout,
): void {
function applyStyle(image: RgbaImage, context: CaptchaGeneratorContext, layout: TextLayout): void {
switch (context.imageStyle) {
case "collision":
applyCollision(image, context);
@@ -667,10 +767,7 @@ function backgroundFor(style: CaptchaConcreteImageStyle): Rgba {
return LIGHT_BACKGROUND;
}
export function renderTextChallenge(
text: string,
context: CaptchaGeneratorContext,
): string {
export function renderTextChallenge(text: string, context: CaptchaGeneratorContext): string {
const layout = layoutFor(text, context.imageStyle);
const image = createImage(300, 104, backgroundFor(context.imageStyle));
+2 -1
View File
@@ -1,7 +1,8 @@
const encoder = new TextEncoder();
export function defaultRandomBytes(length: number): Uint8Array {
if (!Number.isInteger(length) || length < 1) throw new RangeError("random byte length must be positive");
if (!Number.isInteger(length) || length < 1)
throw new RangeError("random byte length must be positive");
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return bytes;
+160 -50
View File
@@ -1,8 +1,19 @@
import { defaultCaptchaGenerators } from "./challenges/index.ts";
import { resolveCaptchaImageStyle } from "./challenges/styles.ts";
import { AssetAudioRenderer } from "./audio/renderer.ts";
import { bindingHash, constantTimeEqual, defaultRandomBytes, hmacSha256, randomId, sha256 } from "./crypto.ts";
import { normalizeSelections, normalizeTextAnswer, normalizedSubmittedAnswer } from "./normalize.ts";
import {
bindingHash,
constantTimeEqual,
defaultRandomBytes,
hmacSha256,
randomId,
sha256,
} from "./crypto.ts";
import {
normalizeSelections,
normalizeTextAnswer,
normalizedSubmittedAnswer,
} from "./normalize.ts";
import { MemoryCaptchaStore } from "./stores/memory.ts";
import type {
CaptchaBinding,
@@ -40,13 +51,16 @@ function failure(
function assertAction(action: string): string {
const value = action.trim();
if (!value || value.length > 128 || !/^[a-z0-9][a-z0-9:._/-]*$/i.test(value)) {
throw new TypeError("CAPTCHA action must be a non-empty stable identifier up to 128 characters");
throw new TypeError(
"CAPTCHA action must be a non-empty stable identifier up to 128 characters",
);
}
return value;
}
function assertPositiveInteger(value: number, name: string): number {
if (!Number.isInteger(value) || value < 1) throw new RangeError(`${name} must be a positive integer`);
if (!Number.isInteger(value) || value < 1)
throw new RangeError(`${name} must be a positive integer`);
return value;
}
@@ -54,7 +68,8 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
const fallback = difficulty === "easy" ? 25 : difficulty === "hard" ? 75 : 50;
if (value === undefined) return fallback;
const numeric = Number(value);
if (!Number.isFinite(numeric)) throw new RangeError("disturbance must be a finite number between 25 and 75");
if (!Number.isFinite(numeric))
throw new RangeError("disturbance must be a finite number between 25 and 75");
const normalized = Math.round(numeric);
if (normalized < 25 || normalized > 75) {
throw new RangeError("disturbance must be between 25 and 75");
@@ -62,8 +77,13 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
return normalized;
}
function randomInteger(randomBytes: (length: number) => Uint8Array, min: number, max: number): number {
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min) throw new RangeError("invalid random range");
function randomInteger(
randomBytes: (length: number) => Uint8Array,
min: number,
max: number,
): number {
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min)
throw new RangeError("invalid random range");
const span = max - min + 1;
if (span === 1) return min;
const limit = Math.floor(0x1_0000_0000 / span) * span;
@@ -74,10 +94,13 @@ function randomInteger(randomBytes: (length: number) => Uint8Array, min: number,
}
}
async function matchesHash(expected: string | undefined, raw: string | undefined): Promise<boolean> {
async function matchesHash(
expected: string | undefined,
raw: string | undefined,
): Promise<boolean> {
if (!expected) return true;
if (!raw) return false;
return constantTimeEqual(expected, await bindingHash(raw) ?? "");
return constantTimeEqual(expected, (await bindingHash(raw)) ?? "");
}
export class DefaultCaptchaEngine implements CaptchaEngine {
@@ -106,9 +129,18 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
this.store = options.store ?? new MemoryCaptchaStore();
this.audioRenderer = options.audioRenderer ?? new AssetAudioRenderer();
this.basePath = `/${(options.basePath ?? "/__wrnexus/captcha").replace(/^\/+|\/+$/g, "")}`;
this.challengeTtlMs = assertPositiveInteger(options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS, "challengeTtlMs");
this.responseTokenTtlMs = assertPositiveInteger(options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS, "responseTokenTtlMs");
this.maxAttempts = assertPositiveInteger(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, "maxAttempts");
this.challengeTtlMs = assertPositiveInteger(
options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS,
"challengeTtlMs",
);
this.responseTokenTtlMs = assertPositiveInteger(
options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS,
"responseTokenTtlMs",
);
this.maxAttempts = assertPositiveInteger(
options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
"maxAttempts",
);
this.minCompletionMs = Math.max(0, options.minCompletionMs ?? DEFAULT_MIN_COMPLETION_MS);
this.responseField = options.responseField ?? DEFAULT_RESPONSE_FIELD;
this.defaultType = options.defaultType ?? "alphanumeric";
@@ -116,19 +148,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
this.bindIp = options.bindIp ?? false;
this.now = options.now ?? Date.now;
this.randomBytes = options.randomBytes ?? defaultRandomBytes;
for (const generator of options.generators ?? defaultCaptchaGenerators()) this.generators.set(generator.type, generator);
if (!this.generators.has(this.defaultType)) throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
for (const generator of options.generators ?? defaultCaptchaGenerators())
this.generators.set(generator.type, generator);
if (!this.generators.has(this.defaultType))
throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
}
async create(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
const action = assertAction(options.action);
const requestedType = options.type ?? this.defaultType;
const requestedPresentation = options.presentation ?? (
requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
const requestedPresentation =
options.presentation ??
(requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
? "invisible"
: "visual"
);
const actualType = requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
: "visual");
const actualType =
requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
const generator = this.generators.get(actualType);
if (!generator) throw new Error(`No CAPTCHA generator registered for ${actualType}`);
const now = this.now();
@@ -161,19 +196,25 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
const id = randomId(this.randomBytes, 24);
const answerSalt = randomId(this.randomBytes, 16);
const caseSensitive = options.caseSensitive ?? false;
const normalizedAnswer = generated.answerKind === "selections"
? normalizeSelections(generated.answer.split(","))
: generated.answerKind === "text"
? normalizeTextAnswer(generated.answer, caseSensitive)
: generated.answer;
const normalizedAnswer =
generated.answerKind === "selections"
? normalizeSelections(generated.answer.split(","))
: generated.answerKind === "text"
? normalizeTextAnswer(generated.answer, caseSensitive)
: generated.answer;
const answerDigest = await hmacSha256(this.secret, `${id}:${answerSalt}:${normalizedAnswer}`);
const expiresAt = now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
const maxAttempts = assertPositiveInteger(options.maxAttempts ?? this.maxAttempts, "maxAttempts");
const expiresAt =
now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
const maxAttempts = assertPositiveInteger(
options.maxAttempts ?? this.maxAttempts,
"maxAttempts",
);
const audioKey = generated.audioSequence?.length ? randomId(this.randomBytes, 18) : undefined;
const responseField = options.responseField ?? this.responseField;
const presentation = requestedPresentation === "audio" && generated.audioSequence?.length
? "audio"
: generated.presentation;
const presentation =
requestedPresentation === "audio" && generated.audioSequence?.length
? "audio"
: generated.presentation;
const publicChallenge: CaptchaChallenge = {
id,
@@ -190,7 +231,9 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
items: presentation === "audio" ? undefined : generated.items,
minSelections: generated.minSelections,
maxSelections: generated.maxSelections,
audioUrl: audioKey ? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}` : undefined,
audioUrl: audioKey
? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}`
: undefined,
refreshUrl: `${this.basePath}/challenge`,
verifyUrl: `${this.basePath}/verify`,
honeypotField: String(generated.metadata?.honeypotField ?? "") || undefined,
@@ -202,9 +245,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
requestedImageStyle: context.requestedImageStyle,
imageStylePool: [...context.imageStylePool],
locale: context.locale,
...(generated.answerKind === "invisible" ? { minCompletionMs: context.minCompletionMs } : {}),
...(generated.answerKind === "invisible"
? { minCompletionMs: context.minCompletionMs }
: {}),
...(generated.metadata?.interaction ? { interaction: generated.metadata.interaction } : {}),
...(requestedType === "image" && actualType !== requestedType ? { alternativeFor: requestedType } : {}),
...(requestedType === "image" && actualType !== requestedType
? { alternativeFor: requestedType }
: {}),
...options.metadata,
},
};
@@ -248,14 +295,23 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
if (!input.challengeId) return failure(action, "missing-input", "Missing CAPTCHA challenge id");
const now = this.now();
const record = await this.store.getChallenge(input.challengeId);
if (!record) return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
if (record.expiresAt <= now) return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
if (record.consumedAt) return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
if (!record)
return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
if (record.expiresAt <= now)
return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
if (record.consumedAt)
return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
const bindingFailure = await this.checkChallengeBinding(record, input, action);
if (bindingFailure) return bindingFailure;
const attempted = await this.store.incrementAttempts(record.id, now);
if (!attempted) return failure(action, "already-used", "The CAPTCHA challenge is no longer available", record.id);
if (!attempted)
return failure(
action,
"already-used",
"The CAPTCHA challenge is no longer available",
record.id,
);
if (attempted.attempts > attempted.maxAttempts) {
await this.store.consumeChallenge(attempted.id, now);
return failure(action, "attempts-exhausted", "Too many CAPTCHA attempts", record.id);
@@ -269,7 +325,10 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
}
const submitted = normalizedSubmittedAnswer(attempted, input);
const digest = await hmacSha256(this.secret, `${attempted.id}:${attempted.answerSalt}:${submitted}`);
const digest = await hmacSha256(
this.secret,
`${attempted.id}:${attempted.answerSalt}:${submitted}`,
);
if (!constantTimeEqual(attempted.answerDigest, digest)) {
const exhausted = attempted.attempts >= attempted.maxAttempts;
if (exhausted) await this.store.consumeChallenge(attempted.id, now);
@@ -282,7 +341,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
}
const consumed = await this.store.consumeChallenge(attempted.id, now);
if (!consumed) return failure(action, "already-used", "The CAPTCHA challenge was already used", attempted.id);
if (!consumed)
return failure(
action,
"already-used",
"The CAPTCHA challenge was already used",
attempted.id,
);
const plainToken = randomId(this.randomBytes, 32);
const tokenHash = await sha256(plainToken);
const expiresAt = now + this.responseTokenTtlMs;
@@ -319,13 +384,33 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
const tokenHash = await sha256(token);
const existing = await this.store.getToken(tokenHash);
if (!existing) return failure(action, "invalid-input", "Unknown CAPTCHA response token");
if (existing.expiresAt <= now) return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
if (existing.consumedAt) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
if (existing.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", existing.challengeId);
if (existing.expiresAt <= now)
return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
if (existing.consumedAt)
return failure(
action,
"already-used",
"The CAPTCHA response token was already used",
existing.challengeId,
);
if (existing.action !== action)
return failure(
action,
"action-mismatch",
"The CAPTCHA action does not match",
existing.challengeId,
);
const bindingFailure = await this.checkTokenBinding(existing, input, action);
if (bindingFailure) return bindingFailure;
const record = input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
if (!record) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
const record =
input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
if (!record)
return failure(
action,
"already-used",
"The CAPTCHA response token was already used",
existing.challengeId,
);
return {
success: true,
provider: "self-hosted",
@@ -338,13 +423,17 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
};
}
async renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
async renderAudio(
challengeId: string,
key: string,
): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
const record = await this.store.getChallenge(challengeId);
if (!record || record.expiresAt <= this.now() || record.consumedAt) return undefined;
const expectedKey = String(record.metadata.audioKey ?? "");
if (!expectedKey || !constantTimeEqual(expectedKey, key)) return undefined;
const sequence = record.metadata.audioSequence;
if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string")) return undefined;
if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string"))
return undefined;
const bytes = await this.audioRenderer.render(sequence, String(record.metadata.locale ?? "en"));
return { bytes, contentType: this.audioRenderer.contentType ?? "audio/wav" };
}
@@ -358,7 +447,8 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
input: CaptchaBinding,
action: string,
): Promise<CaptchaVerificationResult | undefined> {
if (record.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
if (record.action !== action)
return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.id);
}
@@ -366,7 +456,12 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.id);
}
if (!(await matchesHash(record.ipHash, input.ip))) {
return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.id);
return failure(
action,
"ip-mismatch",
"The CAPTCHA network binding does not match",
record.id,
);
}
return undefined;
}
@@ -377,13 +472,28 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
action: string,
): Promise<CaptchaVerificationResult | undefined> {
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.challengeId);
return failure(
action,
"hostname-mismatch",
"The CAPTCHA hostname does not match",
record.challengeId,
);
}
if (!(await matchesHash(record.sessionHash, input.sessionId))) {
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.challengeId);
return failure(
action,
"session-mismatch",
"The CAPTCHA session does not match",
record.challengeId,
);
}
if (!(await matchesHash(record.ipHash, input.ip))) {
return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.challengeId);
return failure(
action,
"ip-mismatch",
"The CAPTCHA network binding does not match",
record.challengeId,
);
}
return undefined;
}
+20 -7
View File
@@ -34,7 +34,8 @@ function json(body: unknown, status = 200, headers: HeadersInit = {}): Response
async function readPayload(request: Request): Promise<Record<string, unknown>> {
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) return (await request.json()) as Record<string, unknown>;
if (contentType.includes("application/json"))
return (await request.json()) as Record<string, unknown>;
if (contentType.includes("form")) {
const form = await request.formData();
const payload: Record<string, unknown> = {};
@@ -88,15 +89,22 @@ export function createCaptchaHttpHandlers(
counters.set(key, counter);
}
counter.count += 1;
return { allowed: counter.count <= maximum, retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)) };
return {
allowed: counter.count <= maximum,
retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)),
};
};
const create = async (request: Request, ctx?: Context): Promise<Response> => {
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
if (request.method !== "POST")
return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
const ip = clientIp(request, ctx, options.trustProxy ?? false);
const limit = withinLimit(`create:${ip}`, options.createLimit ?? 30);
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
if (!limit.allowed)
return json({ success: false, code: "rate-limited" }, 429, {
"retry-after": String(limit.retryAfter),
});
try {
const payload = await readPayload(request);
const challenge = await engine.create({
@@ -118,10 +126,14 @@ export function createCaptchaHttpHandlers(
const verify = async (request: Request, ctx?: Context): Promise<Response> => {
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
if (request.method !== "POST")
return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
const ip = clientIp(request, ctx, options.trustProxy ?? false);
const limit = withinLimit(`verify:${ip}`, options.verifyLimit ?? 60);
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
if (!limit.allowed)
return json({ success: false, code: "rate-limited" }, 429, {
"retry-after": String(limit.retryAfter),
});
try {
const payload = await readPayload(request);
const result = await engine.verify({
@@ -143,7 +155,8 @@ export function createCaptchaHttpHandlers(
const audio = async (request: Request): Promise<Response> => {
if (!originAllowed(request)) return new Response("Forbidden", { status: 403 });
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", { status: 405 });
if (request.method !== "GET" && request.method !== "HEAD")
return new Response("Method Not Allowed", { status: 405 });
const url = new URL(request.url);
const prefix = `${engine.basePath}/audio/`;
const id = decodeURIComponent(url.pathname.slice(prefix.length));
+3 -2
View File
@@ -76,7 +76,8 @@ export function captchaGuard(options: CaptchaGuardOptions) {
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
const result = await verifyRequest(ctx, options);
ctx.locals.captcha = result;
if (!result.success) return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
if (!result.success)
return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
return next();
};
}
@@ -93,7 +94,7 @@ export function captchaPageGate(options: CaptchaPageGateOptions) {
const grants = ctx.session.get<CaptchaSessionGrant[]>(sessionKey) ?? [];
if (validCaptchaGrant(grants, action, now, routeGroup)) return next();
const signals = await options.signals?.(ctx) ?? {};
const signals = (await options.signals?.(ctx)) ?? {};
const decision = shouldRequireCaptcha(action, policy, signals);
ctx.locals.captchaRisk = decision;
if (!decision.challenge) return next();
+4 -1
View File
@@ -13,7 +13,10 @@ export function normalizeSelections(values: unknown): string {
return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort().join(",");
}
export function normalizedSubmittedAnswer(record: CaptchaChallengeRecord, input: VerifyCaptchaInput): string {
export function normalizedSubmittedAnswer(
record: CaptchaChallengeRecord,
input: VerifyCaptchaInput,
): string {
if (record.answerKind === "selections") return normalizeSelections(input.selections);
if (record.answerKind === "invisible") {
return JSON.stringify({
+101 -40
View File
@@ -1,4 +1,3 @@
import { copyFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin } from "@wrnexus/plugin";
@@ -22,17 +21,6 @@ export interface CaptchaAuditIssue {
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const captchaClientRuntime = join(packageRoot, "assets", "client", "captcha.js");
async function copyCaptchaClientRuntime(destinationDir: string): Promise<void> {
await mkdir(destinationDir, { recursive: true });
await copyFile(captchaClientRuntime, join(destinationDir, "captcha.js"));
}
async function installCaptchaClientRuntime(root: string, includeBuildOutput: boolean): Promise<void> {
await copyCaptchaClientRuntime(join(root, "public", "assets", "wrnexus"));
if (includeBuildOutput) {
await copyCaptchaClientRuntime(join(root, "dist", "public", "assets", "wrnexus"));
}
}
export function captchaComponentsDir(): string {
return join(packageRoot, "components");
}
@@ -40,24 +28,54 @@ export function captchaComponentsDir(): string {
function auditCaptchaSource(code: string, file: string, external: boolean): CaptchaAuditIssue[] {
if (!code.includes("<Captcha") && !code.includes('data-component="Captcha"')) return [];
const issues: CaptchaAuditIssue[] = [];
const push = (id: string, severity: CaptchaAuditIssue["severity"], title: string, message: string) =>
issues.push({ id: `${id}:${file}`, severity, title, message, file });
const push = (
id: string,
severity: CaptchaAuditIssue["severity"],
title: string,
message: string,
) => 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.");
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.");
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.");
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.");
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])) {
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",
@@ -67,7 +85,11 @@ function auditCaptchaSource(code: string, file: string, external: boolean): Capt
}
const disturbance = Number(code.match(/disturbance\s*=\s*["']?(\d+)/i)?.[1] ?? "");
if (Number.isFinite(disturbance) && disturbance >= 65 && /showAudio\s*=\s*["']?false/i.test(code)) {
if (
Number.isFinite(disturbance) &&
disturbance >= 65 &&
/showAudio\s*=\s*["']?false/i.test(code)
) {
push(
"hard-without-audio",
"warning",
@@ -75,10 +97,24 @@ function auditCaptchaSource(code: string, file: string, external: boolean): Capt
"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.");
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().");
push(
"server-verification",
"suggestion",
"Server verification required",
"Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().",
);
return issues;
}
@@ -86,37 +122,62 @@ export function captchaPlugin(options: CaptchaPluginOptions = {}) {
const metadataKey = "@wrnexus/captcha:audit";
return definePlugin({
name: "@wrnexus/captcha",
version: "0.3.6",
version: "0.4.0",
enforce: "post",
async configure(config, context) {
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<string, unknown>;
config.captcha = {
componentDir: options.componentDir ?? captchaComponentsDir(),
...current,
};
context.metadata.set("@wrnexus/captcha:component-dir", options.componentDir ?? captchaComponentsDir());
await installCaptchaClientRuntime(context.root, context.command === "build");
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)],
);
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,
}];
return [
{
id: "wrnexus-captcha",
title: "CAPTCHA",
icon: "shield-check",
badge: issues.length,
description: "CAPTCHA security, accessibility, and integration checks",
issues,
},
];
},
});
}
+5 -7
View File
@@ -1,8 +1,4 @@
import type {
CaptchaPolicyOptions,
CaptchaRiskResult,
CaptchaRiskSignals,
} from "./types.ts";
import type { CaptchaPolicyOptions, CaptchaRiskResult, CaptchaRiskSignals } from "./types.ts";
export function evaluateCaptchaRisk(
signals: CaptchaRiskSignals,
@@ -14,8 +10,10 @@ export function evaluateCaptchaRisk(
score = Math.min(100, score + points);
reasons.push(reason);
};
if ((signals.failedAttempts ?? 0) > 0) add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
if ((signals.requestsInWindow ?? 0) > 20) add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
if ((signals.failedAttempts ?? 0) > 0)
add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
if ((signals.requestsInWindow ?? 0) > 20)
add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
if (signals.completionMs !== undefined && signals.completionMs < 700) add(25, "too-fast");
if (signals.missingBrowserSignals) add(20, "missing-browser-signals");
if (signals.suspiciousHeaders) add(20, "suspicious-headers");
+4 -2
View File
@@ -2,7 +2,9 @@ import type { CaptchaProvider } from "../types.ts";
export function defineCaptchaProvider<T extends CaptchaProvider>(provider: T): T {
if (!provider.name) throw new TypeError("Custom CAPTCHA provider requires a stable name");
if (!provider.client?.responseField) throw new TypeError("Custom CAPTCHA provider requires client.responseField");
if (typeof provider.verify !== "function") throw new TypeError("Custom CAPTCHA provider requires verify()");
if (!provider.client?.responseField)
throw new TypeError("Custom CAPTCHA provider requires client.responseField");
if (typeof provider.verify !== "function")
throw new TypeError("Custom CAPTCHA provider requires verify()");
return provider;
}
+11 -5
View File
@@ -32,10 +32,14 @@ export class ManagedCaptchaProvider implements CaptchaProvider {
}
async createChallenge(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
return this.request<CaptchaChallenge>("/v1/challenges", {
siteKey: this.options.siteKey,
...options,
}, false);
return this.request<CaptchaChallenge>(
"/v1/challenges",
{
siteKey: this.options.siteKey,
...options,
},
false,
);
}
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
@@ -64,6 +68,8 @@ export class ManagedCaptchaProvider implements CaptchaProvider {
}
}
export function managedCaptchaProvider(options: ManagedCaptchaProviderOptions): ManagedCaptchaProvider {
export function managedCaptchaProvider(
options: ManagedCaptchaProviderOptions,
): ManagedCaptchaProvider {
return new ManagedCaptchaProvider(options);
}
@@ -22,7 +22,10 @@ export class SelfHostedCaptchaProvider implements CaptchaProvider {
verify(input: VerifyCaptchaInput) {
return input.responseToken || input.providerToken
? this.engine.verifyResponseToken({ ...input, responseToken: input.responseToken ?? input.providerToken })
? this.engine.verifyResponseToken({
...input,
responseToken: input.responseToken ?? input.providerToken,
})
: this.engine.verify(input);
}
}
+44 -16
View File
@@ -61,8 +61,10 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
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");
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 });
@@ -79,7 +81,12 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
signal: controller.signal,
});
if (!response.ok) {
return providerFailure(this.name, action, "provider-error", `${this.name} verification returned HTTP ${response.status}`);
return providerFailure(
this.name,
action,
"provider-error",
`${this.name} verification returned HTTP ${response.status}`,
);
}
const data = (await response.json()) as SiteverifyPayload;
if (!data.success) {
@@ -98,26 +105,45 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
}
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,
});
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,
});
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;
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 providerFailure(
this.name,
action,
"risk-rejected",
"Provider risk score did not pass",
{
score: data.score,
},
);
}
}
return {
@@ -135,7 +161,9 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
return providerFailure(
this.name,
action,
error instanceof DOMException && error.name === "AbortError" ? "network-error" : "provider-error",
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`,
+10 -7
View File
@@ -1,8 +1,4 @@
import type {
CaptchaChallengeRecord,
CaptchaResponseTokenRecord,
CaptchaStore,
} from "../types.ts";
import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
function cloneChallenge(record: CaptchaChallengeRecord): CaptchaChallengeRecord {
return structuredClone(record);
@@ -72,7 +68,10 @@ export class MemoryCaptchaStore implements CaptchaStore {
return record ? cloneToken(record) : undefined;
}
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
async consumeToken(
tokenHash: string,
now: number,
): Promise<CaptchaResponseTokenRecord | undefined> {
const record = this.tokens.get(tokenHash);
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
record.consumedAt = now;
@@ -96,7 +95,11 @@ export class MemoryCaptchaStore implements CaptchaStore {
}
}
private evict<T extends { expiresAt: number }>(map: Map<string, T>, max: number, now: number): void {
private evict<T extends { expiresAt: number }>(
map: Map<string, T>,
max: number,
now: number,
): void {
for (const [key, record] of map) if (record.expiresAt <= now) map.delete(key);
while (map.size >= max) map.delete(map.keys().next().value!);
}
+11 -8
View File
@@ -1,8 +1,4 @@
import type {
CaptchaChallengeRecord,
CaptchaResponseTokenRecord,
CaptchaStore,
} from "../types.ts";
import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
export interface RedisCaptchaClient {
get(key: string): Promise<string | null> | string | null;
@@ -94,7 +90,10 @@ export class RedisCaptchaStore implements CaptchaStore {
return this.read<CaptchaResponseTokenRecord>(this.tokenKey(tokenHash));
}
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
async consumeToken(
tokenHash: string,
now: number,
): Promise<CaptchaResponseTokenRecord | undefined> {
const key = this.tokenKey(tokenHash);
if (this.redis.eval) {
const raw = await this.redis.eval(CONSUME_TOKEN, {
@@ -107,7 +106,9 @@ export class RedisCaptchaStore implements CaptchaStore {
const current = await this.read<CaptchaResponseTokenRecord>(key);
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
current.consumedAt = now;
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
await this.redis.set(key, JSON.stringify(current), {
px: Math.max(1, current.expiresAt - now),
});
return current;
});
}
@@ -138,7 +139,9 @@ export class RedisCaptchaStore implements CaptchaStore {
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
if (operation === "attempt") current.attempts += 1;
else current.consumedAt = now;
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
await this.redis.set(key, JSON.stringify(current), {
px: Math.max(1, current.expiresAt - now),
});
return current;
});
}
+23 -11
View File
@@ -1,8 +1,4 @@
import type {
CaptchaChallengeRecord,
CaptchaResponseTokenRecord,
CaptchaStore,
} from "../types.ts";
import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
export interface SqliteStatementLike {
run(...params: unknown[]): unknown;
@@ -21,16 +17,21 @@ export interface SqliteCaptchaStoreOptions {
}
function safeIdentifier(value: string): string {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new TypeError(`Unsafe SQL identifier: ${value}`);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
throw new TypeError(`Unsafe SQL identifier: ${value}`);
return value;
}
function parseChallenge(row: Record<string, unknown> | undefined): CaptchaChallengeRecord | undefined {
function parseChallenge(
row: Record<string, unknown> | undefined,
): CaptchaChallengeRecord | undefined {
if (!row) return undefined;
return JSON.parse(String(row.payload)) as CaptchaChallengeRecord;
}
function parseToken(row: Record<string, unknown> | undefined): CaptchaResponseTokenRecord | undefined {
function parseToken(
row: Record<string, unknown> | undefined,
): CaptchaResponseTokenRecord | undefined {
if (!row) return undefined;
return JSON.parse(String(row.payload)) as CaptchaResponseTokenRecord;
}
@@ -77,7 +78,13 @@ export class SqliteCaptchaStore implements CaptchaStore {
(id, payload, expires_at, consumed_at, attempts)
VALUES (?, ?, ?, ?, ?)`,
)
.run(record.id, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null, record.attempts);
.run(
record.id,
JSON.stringify(record),
record.expiresAt,
record.consumedAt ?? null,
record.attempts,
);
}
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
@@ -96,7 +103,9 @@ export class SqliteCaptchaStore implements CaptchaStore {
SET payload = ?, attempts = ?
WHERE id = ? AND expires_at > ? AND consumed_at IS NULL AND attempts = ?`,
)
.run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as { changes?: number };
.run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as {
changes?: number;
};
return result?.changes === 0 ? undefined : current;
}
@@ -134,7 +143,10 @@ export class SqliteCaptchaStore implements CaptchaStore {
);
}
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
async consumeToken(
tokenHash: string,
now: number,
): Promise<CaptchaResponseTokenRecord | undefined> {
const current = await this.getToken(tokenHash);
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
current.consumedAt = now;
+8 -8
View File
@@ -1,12 +1,7 @@
import type { Context, Middleware } from "@wrnexus/core";
export type CaptchaProviderName =
| "self-hosted"
| "wrnexus-managed"
| "turnstile"
| "recaptcha"
| "hcaptcha"
| (string & {});
"self-hosted" | "wrnexus-managed" | "turnstile" | "recaptcha" | "hcaptcha" | (string & {});
export type CaptchaChallengeType =
| "number"
@@ -231,7 +226,9 @@ export interface CaptchaGeneratorContext {
export interface CaptchaChallengeGenerator {
readonly type: CaptchaChallengeType;
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge | Promise<GeneratedCaptchaChallenge>;
generate(
context: CaptchaGeneratorContext,
): GeneratedCaptchaChallenge | Promise<GeneratedCaptchaChallenge>;
}
export interface CaptchaAudioRenderer {
@@ -263,7 +260,10 @@ export interface CaptchaEngine {
create(options: CreateCaptchaOptions): Promise<CaptchaChallenge>;
verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
verifyResponseToken(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined>;
renderAudio(
challengeId: string,
key: string,
): Promise<{ bytes: Uint8Array; contentType: string } | undefined>;
gc(): Promise<void>;
}
+3 -6
View File
@@ -1,10 +1,6 @@
import type { Context } from "@wrnexus/core";
import type { ObjectSchema, ParseResult } from "@wrnexus/validation";
import type {
CaptchaEngine,
CaptchaProvider,
CaptchaVerificationResult,
} from "./types.ts";
import type { CaptchaEngine, CaptchaProvider, CaptchaVerificationResult } from "./types.ts";
import { selfHostedProvider } from "./providers/self-hosted.ts";
export interface ParseWithCaptchaOptions {
@@ -28,7 +24,8 @@ export async function parseWithCaptcha<T = Record<string, unknown>>(
options: ParseWithCaptchaOptions,
): Promise<CaptchaParseResult<T>> {
const parsed = schema.parse(input) as ParseResult<T>;
const provider = options.provider ?? (options.engine ? selfHostedProvider(options.engine) : undefined);
const provider =
options.provider ?? (options.engine ? selfHostedProvider(options.engine) : undefined);
if (!provider) throw new TypeError("parseWithCaptcha requires provider or engine");
const field = options.responseField ?? provider.client.responseField;
const token = input[field] ?? input.captchaToken ?? input.responseToken;