release: WRNexusJS 0.5.0

This commit is contained in:
2026-07-29 12:51:10 +05:30
parent 76c768099d
commit 6afe32f63f
456 changed files with 40879 additions and 8850 deletions
+32
View File
@@ -211,6 +211,38 @@ export async function POST(ctx) {
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:
```ts
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:
```wrn
<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
```ts
+1
View File
@@ -8,6 +8,7 @@ CAPTCHA is an anti-automation signal, not authentication or authorization. Conti
- Challenge IDs and response tokens use cryptographically secure random bytes.
- Challenges expire, have attempt limits, and are consumed after a correct answer.
- Response tokens are opaque, hashed at rest, action-bound, and single-use by default.
- Optional `captchaGuard({ verifiedForMs })` grants are session-bound, action-bound, and short-lived; use them only for retryable operations and retain independent rate limits.
- Optional hostname, session, and IP bindings are validated with constant-time comparisons.
- Audio URLs contain a random access key and are private/no-store.
- Public challenge payloads never include the answer. Renderer names and disturbance metadata are safe to expose because they do not reveal the answer.
+22
View File
@@ -95,6 +95,7 @@
showStatus: bool(data.captchaShowStatus, true),
disabled: bool(data.captchaDisabled, false),
required: bool(data.captchaRequired, true),
resetOnError: bool(data.captchaResetOnError, true),
compact: compact,
requiredMessage: data.captchaRequiredMessage || "Please complete the security check.",
incorrectMessage: data.captchaIncorrectMessage || "That answer was not correct. Try again.",
@@ -905,6 +906,7 @@
form: root.closest("form"),
formSubmitListener: null,
formSuccessListener: null,
formErrorListener: null,
};
states.set(root, state);
root.dataset.captchaSize = state.config.size;
@@ -967,8 +969,25 @@
state.formSuccessListener = function () {
createChallenge(state, state.config.presentation);
};
state.formErrorListener = function (event) {
// Verified response tokens are single-use. The receiving API may
// consume one before another form field fails, so retry with a fresh
// challenge instead of reusing an already-consumed token.
var detail = event && event.detail ? event.detail : {};
var captchaFailed =
detail.code === "missing-input" ||
detail.code === "invalid-input" ||
detail.code === "expired" ||
detail.code === "already-used" ||
detail.code === "action-mismatch" ||
detail.code === "hostname-mismatch" ||
detail.code === "session-mismatch";
if (state.config.resetOnError || captchaFailed)
createChallenge(state, state.config.presentation);
};
state.form.addEventListener("submit", state.formSubmitListener, true);
state.form.addEventListener("wire:success", state.formSuccessListener);
state.form.addEventListener("wire:error", state.formErrorListener);
}
updateNotRobotState(state);
@@ -1002,6 +1021,9 @@
if (state.form && state.formSuccessListener) {
state.form.removeEventListener("wire:success", state.formSuccessListener);
}
if (state.form && state.formErrorListener) {
state.form.removeEventListener("wire:error", state.formErrorListener);
}
try {
if (state.externalApi && state.externalWidgetId != null) {
if (typeof state.externalApi.remove === "function")
+2
View File
@@ -39,6 +39,7 @@ component Captcha {
showStatus = true
disabled = false
required = true
resetOnError = true
compact = false
@event ready = function
@@ -87,6 +88,7 @@ component Captcha {
data-captcha-show-status='{showStatus}'
data-captcha-disabled='{disabled}'
data-captcha-required='{required}'
data-captcha-reset-on-error='{resetOnError}'
data-captcha-compact='{compact}'
data-captcha-required-message='{requiredMessage}'
data-captcha-incorrect-message='{incorrectMessage}'
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/captcha",
"version": "0.4.0",
"version": "0.5.0",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
+33
View File
@@ -73,11 +73,44 @@ function defaultFailure(options: CaptchaGuardOptions, result: CaptchaVerificatio
}
export function captchaGuard(options: CaptchaGuardOptions) {
const verifiedForMs = options.verifiedForMs ?? 0;
if (!Number.isFinite(verifiedForMs) || verifiedForMs < 0) {
throw new TypeError("captchaGuard verifiedForMs must be a non-negative finite duration");
}
const sessionKey = options.sessionKey ?? "wrnexus.captcha.grants";
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
const action = resolveAction(options.action, ctx);
const now = Date.now();
const grants = ctx.session.get<CaptchaSessionGrant[]>(sessionKey) ?? [];
const grant = verifiedForMs > 0 ? validCaptchaGrant(grants, action, now) : undefined;
if (grant) {
ctx.locals.captcha = {
success: true,
provider: grant.provider,
action,
};
ctx.locals.captchaVerified = true;
return next();
}
const result = await verifyRequest(ctx, options);
ctx.locals.captcha = result;
if (!result.success)
return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
if (verifiedForMs > 0) {
const fresh: CaptchaSessionGrant = {
action,
provider: result.provider,
expiresAt: now + verifiedForMs,
};
ctx.session.set(sessionKey, [
...grants.filter((item) => item.expiresAt > now && item.action !== action),
fresh,
]);
}
return next();
};
}
+9 -1
View File
@@ -313,6 +313,15 @@ export interface CaptchaGuardOptions {
responseField?: string;
provider?: CaptchaProvider;
engine?: CaptchaEngine;
/**
* Keep successful verification bound to the current session and action for
* this duration. Useful for retryable operations such as login, where a
* valid CAPTCHA should not be repeated after an unrelated credential error.
* Defaults to 0 (every request needs its own response token).
*/
verifiedForMs?: number;
/** Session key used for verified grants. */
sessionKey?: string;
failureStatus?: number;
failureMessage?: string;
bindHostname?: boolean;
@@ -325,7 +334,6 @@ export interface CaptchaPageGateOptions extends CaptchaGuardOptions {
policy?: CaptchaPolicyOptions;
challengePath?: string;
returnToParam?: string;
sessionKey?: string;
signals?: (ctx: Context) => CaptchaRiskSignals | Promise<CaptchaRiskSignals>;
}
@@ -19,10 +19,14 @@ test("Captcha browser runtime is valid JavaScript and exposes the expected lifec
expect(source).toContain("captchaRandomizeStyle");
expect(source).toContain("captchaResolvedImageStyle");
expect(source).toContain("captchaShowListen");
expect(source).toContain("captchaResetOnError");
expect(source).toContain("normalizeSize");
expect(source).toContain("verifyNotRobot");
expect(source).toContain("stopImmediatePropagation");
expect(source).toContain('addEventListener("submit"');
expect(source).toContain('addEventListener("wire:error"');
expect(source).toContain("state.config.resetOnError || captchaFailed");
expect(source).toContain("Verified response tokens are single-use");
expect(source).toContain("MutationObserver");
expect(source).toContain("new CustomEvent(name");
});
@@ -42,6 +46,7 @@ test("Captcha component delegates native browser work to the packaged runtime",
expect(source).toContain("data-captcha-randomize-style");
expect(source).toContain("data-captcha-show-listen");
expect(source).toContain("data-captcha-not-robot-button");
expect(source).toContain("data-captcha-reset-on-error");
expect(source).not.toContain("lifecycle {");
expect(source).not.toContain("async function");
expect(source).not.toContain("await ");
+2
View File
@@ -26,6 +26,7 @@ test("Captcha.wrn parses and exposes the full public contract", async () => {
"color",
"class",
"showListen",
"resetOnError",
]) {
expect(source).toContain(`${prop} =`);
}
@@ -50,6 +51,7 @@ test("Captcha.wrn parses and exposes the full public contract", async () => {
expect(source).toContain("data-captcha-excluded-styles='{excludedStyles}'");
expect(source).toContain("data-captcha-randomize-style='{randomizeStyle}'");
expect(source).toContain("data-captcha-show-listen='{showListen}'");
expect(source).toContain("data-captcha-reset-on-error='{resetOnError}'");
expect(source).toContain("data-captcha-not-robot-button");
expect(source).toContain("data-[captcha-size=compact]");
expect(source).toContain("data-[captcha-size=compact]:max-w-xs");
+56 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { createContext, type Context } from "@wrnexus/core";
import { captchaPageGate } from "../src/middleware.ts";
import { captchaGuard, captchaPageGate } from "../src/middleware.ts";
import type { CaptchaEngine } from "../src/types.ts";
function fakeEngine(): CaptchaEngine {
@@ -54,6 +54,61 @@ function sessionFixture() {
};
}
describe("CAPTCHA request guard", () => {
test("reuses an action-bound verified session grant for retryable requests", async () => {
const session = sessionFixture();
const guard = captchaGuard({
action: "auth-login",
engine: fakeEngine(),
verifiedForMs: 5 * 60_000,
});
const firstRequest = new Request("https://example.test/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ "wrn-captcha-response": "verified-token" }),
});
const firstContext: Context = {
...createContext(firstRequest, new URL(firstRequest.url)),
session,
ip: "127.0.0.1",
};
const first = await guard(
firstContext,
() => new Response("credentials-invalid", { status: 401 }),
);
expect(first.status).toBe(401);
const retryRequest = new Request("https://example.test/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
const retryContext: Context = {
...createContext(retryRequest, new URL(retryRequest.url)),
session,
ip: "127.0.0.1",
};
const retry = await guard(retryContext, () => new Response("retry-allowed"));
expect(await retry.text()).toBe("retry-allowed");
expect(retryContext.locals.captcha).toMatchObject({
success: true,
action: "auth-login",
});
expect(retryContext.locals.captchaVerified).toBe(true);
});
test("rejects invalid verified-session durations", () => {
expect(() =>
captchaGuard({
action: "auth-login",
engine: fakeEngine(),
verifiedForMs: Number.NaN,
}),
).toThrow("verifiedForMs");
});
});
describe("CAPTCHA page gate", () => {
test("accepts a verified form token and grants the protected route", async () => {
const session = sessionFixture();