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
+1 -4
View File
@@ -1,9 +1,6 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import {
createAssetAudioRenderer,
resolveCaptchaAudioAssetsDir,
} from "../src/audio/renderer.ts";
import { createAssetAudioRenderer, resolveCaptchaAudioAssetsDir } from "../src/audio/renderer.ts";
const packageRoot = join(import.meta.dir, "..");
const expectedAssets = join(packageRoot, "assets", "audio");
+4 -3
View File
@@ -24,14 +24,15 @@ test("Captcha browser runtime is valid JavaScript and exposes the expected lifec
expect(source).toContain("stopImmediatePropagation");
expect(source).toContain('addEventListener("submit"');
expect(source).toContain("MutationObserver");
expect(source).toContain('new CustomEvent(name');
expect(source).toContain("new CustomEvent(name");
});
test("Captcha component delegates native browser work to the packaged runtime", async () => {
const source = await readFile(join(packageRoot, "components/Captcha.wrn"), "utf8");
expect(source).toContain('src="/assets/wrnexus/captcha.js"');
expect(source).not.toContain('src="/__wrnexus/captcha.js"');
expect(source).toContain('data-wrnexus-runtime="captcha"');
expect(source).not.toContain("<script");
expect(source).not.toContain("captcha.js");
expect(source).toContain("data-wrn-captcha");
expect(source).toContain("data-captcha-response");
expect(source).toContain("data-captcha-disturbance");
+33 -4
View File
@@ -10,10 +10,38 @@ test("Captcha.wrn parses and exposes the full public contract", async () => {
const ast = parse(source);
expect(ast.kind).toBe("component");
expect(ast.name).toBe("Captcha");
for (const prop of ["provider", "siteKey", "type", "action", "presentation", "difficulty", "disturbance", "imageStyle", "allowedStyles", "excludedStyles", "randomizeStyle", "size", "color", "class", "showListen"]) {
for (const prop of [
"provider",
"siteKey",
"type",
"action",
"presentation",
"difficulty",
"disturbance",
"imageStyle",
"allowedStyles",
"excludedStyles",
"randomizeStyle",
"size",
"color",
"class",
"showListen",
]) {
expect(source).toContain(`${prop} =`);
}
for (const event of ["ready", "challenge", "input", "verify", "success", "failure", "expired", "refresh", "audioStart", "audioEnd", "error"]) {
for (const event of [
"ready",
"challenge",
"input",
"verify",
"success",
"failure",
"expired",
"refresh",
"audioStart",
"audioEnd",
"error",
]) {
expect(source).toContain(`@event ${event} = function`);
}
expect(source).toContain("data-captcha-disturbance='{disturbance}'");
@@ -32,7 +60,8 @@ test("Captcha.wrn parses and exposes the full public contract", async () => {
expect(source).toContain("{...attrs}");
expect(source).toContain("--wire-");
expect(source).not.toMatch(/=\{/);
expect(source).toContain('src="/assets/wrnexus/captcha.js"');
expect(source).not.toContain('src="/__wrnexus/captcha.js"');
expect(source).toContain('data-wrnexus-runtime="captcha"');
expect(source).not.toContain("<script");
expect(source).not.toContain("captcha.js");
expect(source).not.toContain("lifecycle {");
});
+103 -30
View File
@@ -33,46 +33,110 @@ function fixture() {
}
return bytes;
},
audioRenderer: { contentType: "audio/wav", async render() { return new Uint8Array([82, 73, 70, 70]); } },
audioRenderer: {
contentType: "audio/wav",
async render() {
return new Uint8Array([82, 73, 70, 70]);
},
},
});
return { engine, advance: (milliseconds: number) => { now += milliseconds; } };
return {
engine,
advance: (milliseconds: number) => {
now += milliseconds;
},
};
}
describe("self-hosted CAPTCHA engine", () => {
test("creates, solves, and consumes a challenge and response token", async () => {
const { engine } = fixture();
const challenge = await engine.create({ action: "signup", hostname: "example.test", sessionId: "s1" });
const solved = await engine.verify({ challengeId: challenge.id, action: "signup", answer: "42", hostname: "example.test", sessionId: "s1" });
const challenge = await engine.create({
action: "signup",
hostname: "example.test",
sessionId: "s1",
});
const solved = await engine.verify({
challengeId: challenge.id,
action: "signup",
answer: "42",
hostname: "example.test",
sessionId: "s1",
});
expect(solved.success).toBe(true);
expect(solved.responseToken).toBeString();
const accepted = await engine.verifyResponseToken({ responseToken: solved.responseToken, action: "signup", hostname: "example.test", sessionId: "s1" });
const accepted = await engine.verifyResponseToken({
responseToken: solved.responseToken,
action: "signup",
hostname: "example.test",
sessionId: "s1",
});
expect(accepted.success).toBe(true);
const replay = await engine.verifyResponseToken({ responseToken: solved.responseToken, action: "signup", hostname: "example.test", sessionId: "s1" });
const replay = await engine.verifyResponseToken({
responseToken: solved.responseToken,
action: "signup",
hostname: "example.test",
sessionId: "s1",
});
expect(replay).toMatchObject({ success: false, code: "already-used" });
});
test("rejects wrong answers and enforces attempt limits", async () => {
const { engine } = fixture();
const challenge = await engine.create({ action: "login", maxAttempts: 2 });
expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "1" })).toMatchObject({ success: false, code: "incorrect-answer" });
expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "2" })).toMatchObject({ success: false, code: "attempts-exhausted" });
expect(
await engine.verify({ challengeId: challenge.id, action: "login", answer: "1" }),
).toMatchObject({ success: false, code: "incorrect-answer" });
expect(
await engine.verify({ challengeId: challenge.id, action: "login", answer: "2" }),
).toMatchObject({ success: false, code: "attempts-exhausted" });
});
test("binds challenges to actions, hosts, and sessions", async () => {
const { engine } = fixture();
const challenge = await engine.create({ action: "checkout", hostname: "shop.test", sessionId: "abc" });
expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "42", hostname: "shop.test", sessionId: "abc" })).toMatchObject({ code: "action-mismatch" });
expect(await engine.verify({ challengeId: challenge.id, action: "checkout", answer: "42", hostname: "other.test", sessionId: "abc" })).toMatchObject({ code: "hostname-mismatch" });
expect(await engine.verify({ challengeId: challenge.id, action: "checkout", answer: "42", hostname: "shop.test", sessionId: "wrong" })).toMatchObject({ code: "session-mismatch" });
const challenge = await engine.create({
action: "checkout",
hostname: "shop.test",
sessionId: "abc",
});
expect(
await engine.verify({
challengeId: challenge.id,
action: "login",
answer: "42",
hostname: "shop.test",
sessionId: "abc",
}),
).toMatchObject({ code: "action-mismatch" });
expect(
await engine.verify({
challengeId: challenge.id,
action: "checkout",
answer: "42",
hostname: "other.test",
sessionId: "abc",
}),
).toMatchObject({ code: "hostname-mismatch" });
expect(
await engine.verify({
challengeId: challenge.id,
action: "checkout",
answer: "42",
hostname: "shop.test",
sessionId: "wrong",
}),
).toMatchObject({ code: "session-mismatch" });
});
test("expires challenges", async () => {
const { engine, advance } = fixture();
const challenge = await engine.create({ action: "contact", expiresInMs: 1000 });
advance(1001);
expect(await engine.verify({ challengeId: challenge.id, action: "contact", answer: "42" })).toMatchObject({ success: false, code: "expired" });
expect(
await engine.verify({ challengeId: challenge.id, action: "contact", answer: "42" }),
).toMatchObject({ success: false, code: "expired" });
});
test("protects audio with an unguessable challenge key", async () => {
@@ -166,7 +230,9 @@ describe("self-hosted CAPTCHA engine", () => {
imageStyle: "random",
allowedStyles: ["snow", "wave"],
});
expect(["snow", "wave"]).toContain(pooled.metadata?.imageStyle);
const pooledStyle = pooled.metadata?.imageStyle;
expect(typeof pooledStyle).toBe("string");
expect(["snow", "wave"]).toContain(pooledStyle as string);
expect(pooled.metadata?.imageStylePool).toEqual(["snow", "wave"]);
const forced = await engine.create({
@@ -175,29 +241,36 @@ describe("self-hosted CAPTCHA engine", () => {
randomizeStyle: true,
allowedStyles: "cut,striped",
});
expect(["cut", "striped"]).toContain(forced.metadata?.imageStyle);
const forcedStyle = forced.metadata?.imageStyle;
expect(typeof forcedStyle).toBe("string");
expect(["cut", "striped"]).toContain(forcedStyle as string);
expect(forced.metadata?.requestedImageStyle).toBe("classic");
});
test("validates image renderer style pools", async () => {
const { engine } = fixture();
await expect(engine.create({
action: "unknown-style",
imageStyle: "unknown" as never,
})).rejects.toThrow("imageStyle must be one of");
await expect(
engine.create({
action: "unknown-style",
imageStyle: "unknown" as never,
}),
).rejects.toThrow("imageStyle must be one of");
await expect(engine.create({
action: "empty-style-pool",
allowedStyles: ["snow"],
excludedStyles: ["snow"],
})).rejects.toThrow("No CAPTCHA image styles remain");
await expect(
engine.create({
action: "empty-style-pool",
allowedStyles: ["snow"],
excludedStyles: ["snow"],
}),
).rejects.toThrow("No CAPTCHA image styles remain");
await expect(engine.create({
action: "excluded-explicit-style",
imageStyle: "classic",
excludedStyles: ["classic"],
})).rejects.toThrow("is not available in the configured style pool");
await expect(
engine.create({
action: "excluded-explicit-style",
imageStyle: "classic",
excludedStyles: ["classic"],
}),
).rejects.toThrow("is not available in the configured style pool");
});
});
+48 -7
View File
@@ -5,25 +5,66 @@ import type { CaptchaEngine } from "../src/types.ts";
const engine: CaptchaEngine = {
provider: "self-hosted",
basePath: "/api/captcha",
async create(options) { return { id: "c1", provider: "self-hosted", type: "number", presentation: "visual", action: options.action, prompt: "Enter 1", createdAt: 1, expiresAt: 2, responseField: "wrn-captcha-response" }; },
async verify(input) { return { success: input.answer === "1", provider: "self-hosted", action: input.action, code: input.answer === "1" ? undefined : "incorrect-answer" }; },
async verifyResponseToken(input) { return { success: true, provider: "self-hosted", action: input.action }; },
async renderAudio() { return { bytes: new Uint8Array([1, 2, 3]), contentType: "audio/wav" }; },
async create(options) {
return {
id: "c1",
provider: "self-hosted",
type: "number",
presentation: "visual",
action: options.action,
prompt: "Enter 1",
createdAt: 1,
expiresAt: 2,
responseField: "wrn-captcha-response",
};
},
async verify(input) {
return {
success: input.answer === "1",
provider: "self-hosted",
action: input.action,
code: input.answer === "1" ? undefined : "incorrect-answer",
};
},
async verifyResponseToken(input) {
return { success: true, provider: "self-hosted", action: input.action };
},
async renderAudio() {
return { bytes: new Uint8Array([1, 2, 3]), contentType: "audio/wav" };
},
async gc() {},
};
describe("CAPTCHA HTTP handlers", () => {
test("creates and verifies same-origin challenges", async () => {
const handlers = createCaptchaHttpHandlers(engine);
const created = await handlers.handle(new Request("https://example.test/api/captcha/challenge", { method: "POST", headers: { origin: "https://example.test", "content-type": "application/json" }, body: JSON.stringify({ action: "signup" }) }));
const created = await handlers.handle(
new Request("https://example.test/api/captcha/challenge", {
method: "POST",
headers: { origin: "https://example.test", "content-type": "application/json" },
body: JSON.stringify({ action: "signup" }),
}),
);
expect(created?.status).toBe(201);
const verified = await handlers.handle(new Request("https://example.test/api/captcha/verify", { method: "POST", headers: { origin: "https://example.test", "content-type": "application/json" }, body: JSON.stringify({ action: "signup", answer: "1" }) }));
const verified = await handlers.handle(
new Request("https://example.test/api/captcha/verify", {
method: "POST",
headers: { origin: "https://example.test", "content-type": "application/json" },
body: JSON.stringify({ action: "signup", answer: "1" }),
}),
);
expect(verified?.status).toBe(200);
});
test("rejects cross-origin requests", async () => {
const handlers = createCaptchaHttpHandlers(engine);
const response = await handlers.create(new Request("https://example.test/api/captcha/challenge", { method: "POST", headers: { origin: "https://evil.test", "content-type": "application/json" }, body: "{}" }));
const response = await handlers.create(
new Request("https://example.test/api/captcha/challenge", {
method: "POST",
headers: { origin: "https://evil.test", "content-type": "application/json" },
body: "{}",
}),
);
expect(response.status).toBe(403);
});
});
+24 -11
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { createContext, type Context } from "@wrnexus/core";
import { captchaPageGate } from "../src/middleware.ts";
import type { CaptchaEngine } from "../src/types.ts";
@@ -26,17 +27,30 @@ function fakeEngine(): CaptchaEngine {
function sessionFixture() {
const values = new Map<string, unknown>();
let sessionId = "session-1";
return {
id: () => "session-1",
id(): string {
return sessionId;
},
get<T>(key: string): T | undefined {
return values.get(key) as T | undefined;
},
set<T>(key: string, value: T): void {
getAll(): Record<string, unknown> {
return Object.fromEntries(values);
},
set(key: string, value: unknown): void {
values.set(key, value);
},
delete(key: string): void {
values.delete(key);
},
regenerate(): void {
sessionId = `${sessionId}-regenerated`;
},
clear(): void {
values.clear();
},
};
}
@@ -63,12 +77,11 @@ describe("CAPTCHA page gate", () => {
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
const context = {
req: request,
url: new URL(request.url),
const requestUrl = new URL(request.url);
const context: Context = {
...createContext(request, requestUrl),
session,
ip: "127.0.0.1",
locals: {},
};
const granted = await gate(context, () =>
@@ -77,11 +90,11 @@ describe("CAPTCHA page gate", () => {
expect(granted.status).toBe(303);
const protectedRequest = new Request("https://example.test/protected");
const protectedContext = {
...context,
req: protectedRequest,
url: new URL(protectedRequest.url),
locals: {},
const protectedUrl = new URL(protectedRequest.url);
const protectedContext: Context = {
...createContext(protectedRequest, protectedUrl),
session,
ip: "127.0.0.1",
};
const allowed = await gate(protectedContext, () => new Response("unlocked"));
expect(await allowed.text()).toBe("unlocked");
+11 -2
View File
@@ -3,7 +3,11 @@ import { evaluateCaptchaRisk, shouldRequireCaptcha, validCaptchaGrant } from "..
describe("adaptive CAPTCHA policy", () => {
test("raises risk from automation signals", () => {
const result = evaluateCaptchaRisk({ failedAttempts: 3, completionMs: 200, suspiciousHeaders: true });
const result = evaluateCaptchaRisk({
failedAttempts: 3,
completionMs: 200,
suspiciousHeaders: true,
});
expect(result.challenge).toBe(true);
expect(result.reasons).toContain("too-fast");
});
@@ -14,7 +18,12 @@ describe("adaptive CAPTCHA policy", () => {
});
test("accepts an unexpired route grant", () => {
const grant = validCaptchaGrant([{ action: "page", routeGroup: "/reports", provider: "self-hosted", expiresAt: 200 }], "page", 100, "/reports");
const grant = validCaptchaGrant(
[{ action: "page", routeGroup: "/reports", provider: "self-hosted", expiresAt: 200 }],
"page",
100,
"/reports",
);
expect(grant?.provider).toBe("self-hosted");
});
});
+18 -8
View File
@@ -3,11 +3,13 @@ import { turnstileProvider } from "../src/providers/turnstile.ts";
import { recaptchaProvider } from "../src/providers/recaptcha.ts";
import { hcaptchaProvider } from "../src/providers/hcaptcha.ts";
const okFetch = (payload: Record<string, unknown>) => async (_url: string | URL | Request, init?: RequestInit) => {
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({ "content-type": "application/x-www-form-urlencoded" });
return Response.json(payload);
};
const okFetch =
(payload: Record<string, unknown>) =>
async (_url: string | URL | Request, init?: RequestInit) => {
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({ "content-type": "application/x-www-form-urlencoded" });
return Response.json(payload);
};
describe("hosted providers", () => {
test("verifies Turnstile hostname and action", async () => {
@@ -17,7 +19,10 @@ describe("hosted providers", () => {
expectedAction: "signup",
fetch: okFetch({ success: true, hostname: "example.test", action: "signup" }) as typeof fetch,
});
expect(await provider.verify({ action: "signup", providerToken: "token" })).toMatchObject({ success: true, provider: "turnstile" });
expect(await provider.verify({ action: "signup", providerToken: "token" })).toMatchObject({
success: true,
provider: "turnstile",
});
});
test("maps provider duplicate failures", async () => {
@@ -25,7 +30,10 @@ describe("hosted providers", () => {
secretKey: "secret",
fetch: okFetch({ success: false, "error-codes": ["timeout-or-duplicate"] }) as typeof fetch,
});
expect(await provider.verify({ action: "login", providerToken: "token" })).toMatchObject({ success: false, code: "already-used" });
expect(await provider.verify({ action: "login", providerToken: "token" })).toMatchObject({
success: false,
code: "already-used",
});
});
test("sends hCaptcha site key when configured", async () => {
@@ -38,7 +46,9 @@ describe("hosted providers", () => {
return Response.json({ success: true, hostname: "example.test" });
}) as typeof fetch,
});
expect((await provider.verify({ action: "contact", providerToken: "token" })).success).toBe(true);
expect((await provider.verify({ action: "contact", providerToken: "token" })).success).toBe(
true,
);
expect(submitted).toContain("sitekey=site-key");
});
});
+1 -4
View File
@@ -6,10 +6,7 @@ import {
resolveCaptchaImageStyle,
} from "../src/challenges/styles.ts";
import { renderTextChallenge } from "../src/challenges/visual.ts";
import type {
CaptchaConcreteImageStyle,
CaptchaGeneratorContext,
} from "../src/types.ts";
import type { CaptchaConcreteImageStyle, CaptchaGeneratorContext } from "../src/types.ts";
function random(seedValue = 17) {
let seed = seedValue >>> 0;