release: WRNexusJS 0.5.10

This commit is contained in:
2026-07-30 13:36:29 +05:30
parent 8fc6f15402
commit d1b0c55b53
159 changed files with 7509 additions and 604 deletions
+27 -5
View File
@@ -415,7 +415,12 @@
}
function toggleItem(state, itemId, button) {
if (state.config.disabled || state.status !== "ready") return;
if (state.config.disabled || (state.status !== "ready" && state.status !== "incorrect")) return;
// An incorrect answer is still the same usable challenge until the server
// expires it or exhausts its attempts. Let the user amend the selection
// immediately and clear the stale error/locked control state.
if (state.status === "incorrect") setStatus(state, "ready", "");
var index = state.selections.indexOf(itemId);
var selected = index >= 0;
@@ -867,10 +872,8 @@
});
}
function onFormSubmit(state, event) {
if (!state.config.required || state.responseToken) return;
event.preventDefault();
event.stopImmediatePropagation();
function passesSubmitGuard(state) {
if (!state.config.required || state.responseToken) return true;
setStatus(state, "incorrect", state.config.requiredMessage);
emit(state, "failure", { code: "missing-input" });
var target =
@@ -878,6 +881,15 @@
state.root.querySelector("[data-captcha-not-robot-button]") ||
state.root.querySelector("[data-captcha-verify]");
if (target && typeof target.focus === "function") target.focus();
return false;
}
function onFormSubmit(state, event) {
// Schema-backed forms run this guard after their ordinary fields validate.
if (state.form && state.form.__wireValidateBound) return;
if (passesSubmitGuard(state)) return;
event.preventDefault();
event.stopImmediatePropagation();
}
function initialize(root) {
@@ -963,6 +975,11 @@
});
if (state.form) {
state.submitGuard = function () {
return passesSubmitGuard(state);
};
state.form.__wireSubmitGuards = state.form.__wireSubmitGuards || [];
state.form.__wireSubmitGuards.push(state.submitGuard);
state.formSubmitListener = function (event) {
onFormSubmit(state, event);
};
@@ -1018,6 +1035,11 @@
if (state.form && state.formSubmitListener) {
state.form.removeEventListener("submit", state.formSubmitListener, true);
}
if (state.form && state.form.__wireSubmitGuards && state.submitGuard) {
state.form.__wireSubmitGuards = state.form.__wireSubmitGuards.filter(function (guard) {
return guard !== state.submitGuard;
});
}
if (state.form && state.formSuccessListener) {
state.form.removeEventListener("wire:success", state.formSuccessListener);
}
+21 -17
View File
@@ -41,6 +41,7 @@ component Captcha {
required = true
resetOnError = true
compact = false
layout = "vertical"
@event ready = function
@event challenge = function
@@ -90,6 +91,7 @@ component Captcha {
data-captcha-required='{required}'
data-captcha-reset-on-error='{resetOnError}'
data-captcha-compact='{compact}'
data-captcha-layout='{layout}'
data-captcha-required-message='{requiredMessage}'
data-captcha-incorrect-message='{incorrectMessage}'
data-captcha-expired-message='{expiredMessage}'
@@ -97,9 +99,9 @@ component Captcha {
data-captcha-status="idle"
data-server-verification-required="true"
aria-busy="false"
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-layout=horizontal]:max-w-2xl data-[captcha-layout=horizontal]:grid data-[captcha-layout=horizontal]:grid-cols-1 sm:data-[captcha-layout=horizontal]:grid-cols-[minmax(0,1fr)_minmax(12rem,0.8fr)] sm:data-[captcha-layout=horizontal]:items-start data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
>
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-type=not-robot]/captcha:hidden'>
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-layout=horizontal]/captcha:col-span-full group-data-[captcha-type=not-robot]/captcha:hidden'>
<div class='min-w-0'>
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
<span
@@ -140,7 +142,7 @@ component Captcha {
Loading challenge…
</div>
<div data-captcha-challenge hidden class='flex flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
<div data-captcha-challenge hidden class='flex min-w-0 flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
<div data-captcha-prompt-row class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-2'>
<p data-captcha-prompt class='m-0 text-sm font-medium text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4'></p>
<span data-captcha-countdown hidden class='shrink-0 text-xs tabular-nums text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px]'></span>
@@ -219,20 +221,6 @@ component Captcha {
</button>
</template>
<label data-captcha-answer-wrap hidden class='flex flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
<input
data-captcha-answer
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
placeholder="Enter the answer"
/>
</label>
<input
data-captcha-honeypot
type="text"
@@ -245,6 +233,21 @@ component Captcha {
/>
</div>
<div data-captcha-response-panel class='flex min-w-0 flex-col gap-2 group-data-[captcha-layout=horizontal]/captcha:self-stretch'>
<label data-captcha-answer-wrap hidden class='flex min-w-0 flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
<input
data-captcha-answer
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
placeholder="Enter the answer"
/>
</label>
<div data-captcha-success hidden role="status" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
<span aria-hidden="true" class='icon-[lucide--circle-check] mt-0.5 size-3.5 shrink-0'></span>
<span data-captcha-success-message>Verification completed.</span>
@@ -312,6 +315,7 @@ component Captcha {
<p data-captcha-credit class='m-0 text-[11px] leading-4 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-type=not-robot]/captcha:hidden'>
Protected by WRNexus CAPTCHA. Server-side verification is required.
</p>
</div>
</section>
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/captcha",
"version": "0.5.1",
"version": "0.5.10",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
@@ -38,7 +38,7 @@
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck \u0026\u0026 bun run test"
"check": "bun run typecheck && bun run test"
},
"dependencies": {
"@wrnexus/core": "workspace:*",
+18 -4
View File
@@ -38,6 +38,13 @@ const DEFAULT_TOKEN_TTL_MS = 300_000;
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_MIN_COMPLETION_MS = 800;
const DEFAULT_RESPONSE_FIELD = "wrn-captcha-response";
const RANDOM_CHALLENGE_TYPES: CaptchaChallengeType[] = [
"number",
"alpha",
"alphanumeric",
"calculation",
"image",
];
function failure(
action: string,
@@ -150,13 +157,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
this.randomBytes = options.randomBytes ?? defaultRandomBytes;
for (const generator of options.generators ?? defaultCaptchaGenerators())
this.generators.set(generator.type, generator);
if (!this.generators.has(this.defaultType))
if (this.defaultType !== "random" && !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 configuredType = options.type ?? this.defaultType;
const randomInt = (min: number, max: number): number =>
randomInteger(this.randomBytes, min, max);
const randomTypes = RANDOM_CHALLENGE_TYPES.filter((type) => this.generators.has(type));
if (configuredType === "random" && randomTypes.length === 0)
throw new Error("No interactive CAPTCHA generators are registered");
const requestedType =
configuredType === "random"
? randomTypes[randomInt(0, randomTypes.length - 1)]
: configuredType;
const requestedPresentation =
options.presentation ??
(requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
@@ -169,8 +185,6 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
const now = this.now();
const difficulty = options.difficulty ?? this.defaultDifficulty;
const disturbance = normalizeDisturbance(options.disturbance, difficulty);
const randomInt = (min: number, max: number): number =>
randomInteger(this.randomBytes, min, max);
const imageStyle = resolveCaptchaImageStyle({
imageStyle: options.imageStyle,
allowedStyles: options.allowedStyles,
+1
View File
@@ -4,6 +4,7 @@ export type CaptchaProviderName =
"self-hosted" | "wrnexus-managed" | "turnstile" | "recaptcha" | "hcaptcha" | (string & {});
export type CaptchaChallengeType =
| "random"
| "number"
| "alpha"
| "alphanumeric"
@@ -23,9 +23,14 @@ test("Captcha browser runtime is valid JavaScript and exposes the expected lifec
expect(source).toContain("normalizeSize");
expect(source).toContain("verifyNotRobot");
expect(source).toContain("stopImmediatePropagation");
expect(source).toContain("__wireSubmitGuards");
expect(source).toContain("__wireValidateBound");
expect(source).toContain("passesSubmitGuard");
expect(source).toContain('addEventListener("submit"');
expect(source).toContain('addEventListener("wire:error"');
expect(source).toContain("state.config.resetOnError || captchaFailed");
expect(source).toContain('state.status !== "ready" && state.status !== "incorrect"');
expect(source).toContain('state.status === "incorrect") setStatus(state, "ready", "")');
expect(source).toContain("Verified response tokens are single-use");
expect(source).toContain("MutationObserver");
expect(source).toContain("new CustomEvent(name");
+16
View File
@@ -49,6 +49,22 @@ function fixture() {
}
describe("self-hosted CAPTCHA engine", () => {
test("random challenges resolve to an interactive challenge type", async () => {
const engine = createCaptchaEngine({
secret: "captcha-test-secret-with-at-least-thirty-two-characters",
store: new MemoryCaptchaStore(),
defaultType: "random",
minCompletionMs: 0,
});
const supported = new Set(["number", "alpha", "alphanumeric", "calculation", "image"]);
for (let index = 0; index < 12; index += 1) {
const challenge = await engine.create({ action: "random-check" });
expect(supported.has(challenge.type)).toBe(true);
expect(challenge.type).not.toBe("random");
}
});
test("creates, solves, and consumes a challenge and response token", async () => {
const { engine } = fixture();
const challenge = await engine.create({