release: WRNexusJS 0.8.0
This commit is contained in:
@@ -360,3 +360,14 @@ const engine = createCaptchaEngine({ secret, generators: [wordChallenge] });
|
||||
```
|
||||
|
||||
Applications may also implement `CaptchaStore`, `CaptchaAudioRenderer`, or use `defineCaptchaProvider()` for a completely custom service.
|
||||
|
||||
## Helper and block kit
|
||||
|
||||
The package exports `captchaTokenFrom`, `captchaHeaders`, `captchaFields`, `verifyCaptcha`, `verifyCaptchaOrThrow`, `captchaResultResponse`, and `captchaContext` for consistent server and client integration.
|
||||
|
||||
Enable the CAPTCHA plugin to use the low-level `<Captcha />` challenge plus complete UI-composed blocks:
|
||||
|
||||
- `<CaptchaField />`
|
||||
- `<CaptchaStatus />`
|
||||
|
||||
`CaptchaField` composes `Card` from `@wrnexus/ui` and keeps the CAPTCHA-specific size separate from the surrounding UI size.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
component CaptchaField {
|
||||
props {
|
||||
title: string = "Security verification"
|
||||
description: string = "Complete the challenge before submitting."
|
||||
provider: string = "self-hosted"
|
||||
type: string = "alphanumeric"
|
||||
action: string = "form-submit"
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
captchaSize: string = "normal"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<Card {...attrs} title='{title}' description='{description}' color='{color}' size='{size}' class='{class}'>
|
||||
<Captcha provider='{provider}' type='{type}' action='{action}' color='{color}' size='{captchaSize}' compact='{size == "sm"}' />
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
component CaptchaStatus {
|
||||
props {
|
||||
success: boolean = false
|
||||
message: string = ""
|
||||
successMessage: string = "Security verification completed."
|
||||
failureMessage: string = "Security verification is required."
|
||||
color: string = "primary"
|
||||
size: string = "sm"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<Alert
|
||||
{...attrs}
|
||||
title='{success ? "Verified" : "Verification required"}'
|
||||
description='{message || (success ? successMessage : failureMessage)}'
|
||||
icon='{success ? "icon-[lucide--shield-check]" : "icon-[lucide--shield-alert]"}'
|
||||
color='{success ? "success" : color}'
|
||||
variant="soft"
|
||||
size='{size}'
|
||||
class='{class}'
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -43,10 +43,11 @@
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*"
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type {
|
||||
CaptchaEngine,
|
||||
CaptchaProvider,
|
||||
CaptchaVerificationResult,
|
||||
VerifyCaptchaInput,
|
||||
} from "./types.ts";
|
||||
import { selfHostedProvider } from "./providers/self-hosted.ts";
|
||||
|
||||
export function captchaTokenFrom(
|
||||
value: Request | Headers | FormData | URLSearchParams | Record<string, unknown>,
|
||||
field = "wrn-captcha-response",
|
||||
): Promise<string | undefined> | string | undefined {
|
||||
if (value instanceof Request) {
|
||||
const header = value.headers.get("x-wrn-captcha-token");
|
||||
if (header) return header;
|
||||
return value
|
||||
.clone()
|
||||
.formData()
|
||||
.then((form) => {
|
||||
const token = form.get(field) ?? form.get("captchaToken") ?? form.get("responseToken");
|
||||
return typeof token === "string" ? token : undefined;
|
||||
})
|
||||
.catch(async () => {
|
||||
try {
|
||||
const body = (await value.clone().json()) as Record<string, unknown>;
|
||||
const token = body[field] ?? body.captchaToken ?? body.responseToken;
|
||||
return token === undefined ? undefined : String(token);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (value instanceof Headers) {
|
||||
return value.get("x-wrn-captcha-token") ?? value.get("x-captcha-token") ?? undefined;
|
||||
}
|
||||
if (value instanceof FormData || value instanceof URLSearchParams) {
|
||||
const token = value.get(field) ?? value.get("captchaToken") ?? value.get("responseToken");
|
||||
return typeof token === "string" ? token : undefined;
|
||||
}
|
||||
const token = value[field] ?? value.captchaToken ?? value.responseToken;
|
||||
return token === undefined ? undefined : String(token);
|
||||
}
|
||||
|
||||
export function captchaHeaders(token: string): HeadersInit {
|
||||
return { "x-wrn-captcha-token": token };
|
||||
}
|
||||
|
||||
export function captchaFields(
|
||||
token: string,
|
||||
field = "wrn-captcha-response",
|
||||
): Record<string, string> {
|
||||
return { [field]: token };
|
||||
}
|
||||
|
||||
export async function verifyCaptcha(
|
||||
providerOrEngine: CaptchaProvider | CaptchaEngine,
|
||||
input: VerifyCaptchaInput,
|
||||
): Promise<CaptchaVerificationResult> {
|
||||
const provider: CaptchaProvider =
|
||||
"client" in providerOrEngine
|
||||
? (providerOrEngine as CaptchaProvider)
|
||||
: selfHostedProvider(providerOrEngine as CaptchaEngine);
|
||||
return provider.verify(input);
|
||||
}
|
||||
|
||||
export async function verifyCaptchaOrThrow(
|
||||
providerOrEngine: CaptchaProvider | CaptchaEngine,
|
||||
input: VerifyCaptchaInput,
|
||||
): Promise<CaptchaVerificationResult> {
|
||||
const result = await verifyCaptcha(providerOrEngine, input);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`WRN-CAPTCHA-${String(result.code ?? "FAILED").toUpperCase()}: ${result.message ?? "CAPTCHA verification failed"}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function captchaResultResponse(result: CaptchaVerificationResult): Response {
|
||||
return Response.json(result, {
|
||||
status: result.success ? 200 : 403,
|
||||
headers: { "cache-control": "no-store", "x-content-type-options": "nosniff" },
|
||||
});
|
||||
}
|
||||
|
||||
export function captchaContext(ctx: Context): CaptchaVerificationResult | null {
|
||||
const result = ctx.locals.captcha;
|
||||
return result && typeof result === "object" ? (result as CaptchaVerificationResult) : null;
|
||||
}
|
||||
@@ -12,3 +12,4 @@ export * from "./providers/index.ts";
|
||||
export * from "./challenges/index.ts";
|
||||
export * from "./audio/index.ts";
|
||||
export * from "./crypto.ts";
|
||||
export * from "./helpers.ts";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { captchaFields, captchaHeaders, captchaTokenFrom } from "../src/index.ts";
|
||||
|
||||
describe("CAPTCHA helper kit", () => {
|
||||
test("reads standard response tokens from forms, headers, and objects", async () => {
|
||||
const form = new FormData();
|
||||
form.set("wrn-captcha-response", "form-token");
|
||||
expect(await captchaTokenFrom(form)).toBe("form-token");
|
||||
expect(await captchaTokenFrom(new Headers({ "x-wrn-captcha-token": "header-token" }))).toBe(
|
||||
"header-token",
|
||||
);
|
||||
expect(await captchaTokenFrom({ "wrn-captcha-response": "object-token" })).toBe("object-token");
|
||||
});
|
||||
|
||||
test("creates consistent fields and headers", () => {
|
||||
expect(captchaFields("token")).toEqual({ "wrn-captcha-response": "token" });
|
||||
expect(new Headers(captchaHeaders("token")).get("x-wrn-captcha-token")).toBe("token");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user