release: WRNexusJS 0.5.10
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user