release: WRNexusJS 0.4.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
|
||||
@@ -245,3 +245,5 @@ export function createAI(config: AIConfig = {}): AI {
|
||||
|
||||
return { generate, stream, streamResponse };
|
||||
}
|
||||
export { anthropicProvider, aiProvider, createAIClient } from "./providers.ts";
|
||||
export type { AIUsage, AIResult, AIProvider, AIClient, AIClientOptions } from "./providers.ts";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
AIError,
|
||||
createAI,
|
||||
type AI,
|
||||
type AIConfig,
|
||||
type GenerateOptions,
|
||||
type Message,
|
||||
} from "./index.ts";
|
||||
|
||||
export interface AIUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
export interface AIResult<T = string> {
|
||||
value: T;
|
||||
provider: string;
|
||||
model?: string;
|
||||
usage?: AIUsage;
|
||||
finishReason?: string;
|
||||
raw?: unknown;
|
||||
}
|
||||
export interface AIProvider {
|
||||
name: string;
|
||||
generate(prompt: string | Message[], options?: GenerateOptions): Promise<AIResult<string>>;
|
||||
stream?(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions,
|
||||
): AsyncGenerator<string, void, unknown>;
|
||||
}
|
||||
export interface AIClientOptions {
|
||||
providers: AIProvider[];
|
||||
fallback?: boolean;
|
||||
onAttempt?: (provider: string, error?: unknown) => void | Promise<void>;
|
||||
}
|
||||
export interface AIClient {
|
||||
generate(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions & { provider?: string },
|
||||
): Promise<AIResult<string>>;
|
||||
generateObject<T>(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions & { provider?: string; validate?: (value: unknown) => value is T },
|
||||
): Promise<AIResult<T>>;
|
||||
stream(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions & { provider?: string },
|
||||
): AsyncGenerator<string, void, unknown>;
|
||||
}
|
||||
|
||||
export function anthropicProvider(config: AIConfig = {}): AIProvider {
|
||||
const client = createAI(config);
|
||||
return {
|
||||
name: "anthropic",
|
||||
async generate(prompt: string | Message[], options?: GenerateOptions) {
|
||||
return {
|
||||
value: await client.generate(prompt, options),
|
||||
provider: "anthropic",
|
||||
model: options?.model ?? config.model,
|
||||
};
|
||||
},
|
||||
stream: (prompt: string | Message[], options?: GenerateOptions) =>
|
||||
client.stream(prompt, options),
|
||||
};
|
||||
}
|
||||
|
||||
export function aiProvider(name: string, client: AI): AIProvider {
|
||||
return {
|
||||
name,
|
||||
async generate(prompt: string | Message[], options?: GenerateOptions) {
|
||||
return {
|
||||
value: await client.generate(prompt, options),
|
||||
provider: name,
|
||||
model: options?.model,
|
||||
};
|
||||
},
|
||||
stream: (prompt: string | Message[], options?: GenerateOptions) =>
|
||||
client.stream(prompt, options),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonText(value: string): string {
|
||||
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(value);
|
||||
return (fenced?.[1] ?? value).trim();
|
||||
}
|
||||
|
||||
export function createAIClient(options: AIClientOptions): AIClient {
|
||||
if (!options.providers.length) throw new Error("WRN-AI-NO-PROVIDERS");
|
||||
const select = (name?: string) =>
|
||||
name ? options.providers.filter((provider) => provider.name === name) : options.providers;
|
||||
const generate: AIClient["generate"] = async (prompt, callOptions = {}) => {
|
||||
const providers = select(callOptions.provider);
|
||||
if (!providers.length)
|
||||
throw new AIError(`Unknown AI provider: ${callOptions.provider}`, 0, "provider_error");
|
||||
let last: unknown;
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
await options.onAttempt?.(provider.name);
|
||||
return await provider.generate(prompt, callOptions);
|
||||
} catch (error) {
|
||||
last = error;
|
||||
await options.onAttempt?.(provider.name, error);
|
||||
if (options.fallback === false || callOptions.provider) throw error;
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
};
|
||||
return {
|
||||
generate,
|
||||
async generateObject<T>(
|
||||
prompt: string | Message[],
|
||||
callOptions: GenerateOptions & {
|
||||
provider?: string;
|
||||
validate?: (value: unknown) => value is T;
|
||||
} = {},
|
||||
) {
|
||||
const result = await generate(prompt, callOptions);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(jsonText(result.value));
|
||||
} catch {
|
||||
throw new AIError("AI response was not valid JSON", 0, "structured_output_error");
|
||||
}
|
||||
if (callOptions.validate && !callOptions.validate(value))
|
||||
throw new AIError(
|
||||
"AI response failed structured output validation",
|
||||
0,
|
||||
"structured_output_error",
|
||||
);
|
||||
return { ...result, value: value as T };
|
||||
},
|
||||
async *stream(prompt, callOptions = {}) {
|
||||
const providers = select(callOptions.provider);
|
||||
let last: unknown;
|
||||
for (const provider of providers) {
|
||||
if (!provider.stream) continue;
|
||||
try {
|
||||
yield* provider.stream(prompt, callOptions);
|
||||
return;
|
||||
} catch (error) {
|
||||
last = error;
|
||||
if (options.fallback === false || callOptions.provider) throw error;
|
||||
}
|
||||
}
|
||||
if (last) throw last;
|
||||
const result = await generate(prompt, callOptions);
|
||||
yield result.value;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
import type { Policy, Subject } from "./index.ts";
|
||||
|
||||
export interface AuthorizationDecision {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
policy?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
export type DecisionPolicy<S = Subject, R = unknown> = (
|
||||
subject: S,
|
||||
resource?: R,
|
||||
) => AuthorizationDecision | Promise<AuthorizationDecision>;
|
||||
|
||||
export function allow(reason?: string, metadata?: Record<string, unknown>): AuthorizationDecision {
|
||||
return { allowed: true, reason, metadata };
|
||||
}
|
||||
export function deny(
|
||||
reason = "Forbidden",
|
||||
metadata?: Record<string, unknown>,
|
||||
): AuthorizationDecision {
|
||||
return { allowed: false, reason, metadata };
|
||||
}
|
||||
export function decision<S, R>(
|
||||
name: string,
|
||||
policy: Policy<S, R>,
|
||||
denial = "Policy denied access",
|
||||
): DecisionPolicy<S, R> {
|
||||
return async (subject, resource) => {
|
||||
const allowed = await policy(subject, resource);
|
||||
return {
|
||||
allowed,
|
||||
reason: allowed ? undefined : denial,
|
||||
policy: name,
|
||||
};
|
||||
};
|
||||
}
|
||||
export function owner<SubjectType extends Subject, Resource extends Record<string, unknown>>(
|
||||
subjectKey: keyof SubjectType = "id",
|
||||
resourceKey: keyof Resource | string = "userId",
|
||||
): DecisionPolicy<SubjectType, Resource> {
|
||||
return (subject, resource) =>
|
||||
resource && Object.is(subject[subjectKey], resource[resourceKey as keyof Resource])
|
||||
? allow("resource owner")
|
||||
: deny("resource ownership required");
|
||||
}
|
||||
export function anyDecision<S, R>(...policies: DecisionPolicy<S, R>[]): DecisionPolicy<S, R> {
|
||||
return async (subject, resource) => {
|
||||
const denied: AuthorizationDecision[] = [];
|
||||
for (const policy of policies) {
|
||||
const result = await policy(subject, resource);
|
||||
if (result.allowed) return result;
|
||||
denied.push(result);
|
||||
}
|
||||
return deny(
|
||||
denied
|
||||
.map((item) => item.reason)
|
||||
.filter(Boolean)
|
||||
.join("; ") || "No policy allowed access",
|
||||
);
|
||||
};
|
||||
}
|
||||
export function allDecisions<S, R>(...policies: DecisionPolicy<S, R>[]): DecisionPolicy<S, R> {
|
||||
return async (subject, resource) => {
|
||||
for (const policy of policies) {
|
||||
const result = await policy(subject, resource);
|
||||
if (!result.allowed) return result;
|
||||
}
|
||||
return allow("all policies passed");
|
||||
};
|
||||
}
|
||||
export function authorizeDecision(
|
||||
evaluate: (ctx: Context) => AuthorizationDecision | Promise<AuthorizationDecision>,
|
||||
): Middleware {
|
||||
return async (ctx, next) => {
|
||||
const result = await evaluate(ctx);
|
||||
if (result.allowed) return next();
|
||||
return Response.json(
|
||||
{ ok: false, error: "Forbidden", reason: result.reason, policy: result.policy },
|
||||
{ status: 403 },
|
||||
);
|
||||
};
|
||||
}
|
||||
export function filterAuthorized<S, R>(
|
||||
subject: S,
|
||||
values: readonly R[],
|
||||
policy: Policy<S, R>,
|
||||
): Promise<R[]> {
|
||||
return Promise.all(
|
||||
values.map(async (value) => ({ value, allowed: await policy(subject, value) })),
|
||||
).then((results) => results.filter((result) => result.allowed).map((result) => result.value));
|
||||
}
|
||||
@@ -128,3 +128,14 @@ export function requireRole(...roles: string[]): Middleware {
|
||||
export function requirePermission(rbac: Rbac, permission: string): Middleware {
|
||||
return authorize((ctx) => rbac.can(ctx.user as Subject | undefined, permission));
|
||||
}
|
||||
export {
|
||||
allow,
|
||||
deny,
|
||||
decision,
|
||||
owner,
|
||||
anyDecision,
|
||||
allDecisions,
|
||||
authorizeDecision,
|
||||
filterAuthorized,
|
||||
} from "./advanced.ts";
|
||||
export type { AuthorizationDecision, DecisionPolicy } from "./advanced.ts";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
any,
|
||||
all,
|
||||
attr,
|
||||
decision,
|
||||
type Policy,
|
||||
} from "../src/index.ts";
|
||||
|
||||
@@ -78,3 +79,17 @@ test("guards: authorize / requireRole / requirePermission", async () => {
|
||||
(await authorize((c) => (c.user as User)?.id === "u1")(ctx({ id: "u1" }), ok)).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
test("explainable decisions only include denial reasons when denied", async () => {
|
||||
const policy = decision("owner", (subject: User) => subject.id === "u1");
|
||||
expect(await policy({ id: "u1" })).toEqual({
|
||||
allowed: true,
|
||||
reason: undefined,
|
||||
policy: "owner",
|
||||
});
|
||||
expect(await policy({ id: "u2" })).toEqual({
|
||||
allowed: false,
|
||||
reason: "Policy denied access",
|
||||
policy: "owner",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
A first-class CAPTCHA and anti-automation package for WRNexusJS. It supports self-hosted challenges, a managed WRNexus service, external providers, form submission guards, page gates, accessible audio, adaptive risk checks, and a Tailwind-only `.wrn` component.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/captcha
|
||||
```
|
||||
|
||||
WRNexusJS automatically discovers the package plugin, component, client runtime, styles, and DevToolbar audit. Use `<Captcha />` directly after installation. The browser runtime is injected once only on responses that render a CAPTCHA; no script tag, public-file copy, or manual plugin registration is required. Call `captchaPlugin(options)` explicitly only when an application needs to override the discovered package configuration.
|
||||
|
||||
## Included challenge modes
|
||||
|
||||
- Number, alphabet, and alphanumeric image challenges
|
||||
@@ -197,9 +205,7 @@ export async function POST(ctx) {
|
||||
const validation = await parseBody(contactSchema, ctx.req.clone());
|
||||
if (!validation.ok) return validation.response;
|
||||
|
||||
return guard(ctx, async () =>
|
||||
Response.json({ ok: true, submission: validation.value }),
|
||||
);
|
||||
return guard(ctx, async () => Response.json({ ok: true, submission: validation.value }));
|
||||
}
|
||||
```
|
||||
|
||||
@@ -291,7 +297,7 @@ For direct browser challenge creation, configure the component’s `endpoint` as
|
||||
|
||||
## DevToolbar
|
||||
|
||||
Register `captchaPlugin()` in the WRNexusJS config. The audit panel checks for likely client-side secrets, missing action bindings, missing provider site keys, optional CAPTCHA fields, accessible alternatives, and server-verification reminders.
|
||||
The automatically discovered CAPTCHA plugin registers its DevToolbar audit panel. It checks for likely client-side secrets, missing action bindings, missing provider site keys, optional CAPTCHA fields, accessible alternatives, and server-verification reminders. Explicit `captchaPlugin(options)` registration is needed only to override automatic configuration.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global Audio, CSS, CustomEvent, Element, HTMLElement, MutationObserver, URL, clearInterval, clearTimeout, document, fetch, window */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
@@ -5,6 +6,11 @@
|
||||
var existingRuntime = window[RUNTIME_KEY];
|
||||
|
||||
if (existingRuntime && typeof existingRuntime.scan === "function") {
|
||||
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
|
||||
window.__wrnexusRuntimes.captcha = {
|
||||
mount: existingRuntime.scan,
|
||||
unmount: existingRuntime.unmount || function () {},
|
||||
};
|
||||
existingRuntime.scan(document);
|
||||
return;
|
||||
}
|
||||
@@ -26,7 +32,9 @@
|
||||
|
||||
function normalizeSize(value, compact) {
|
||||
if (compact) return "compact";
|
||||
var normalized = String(value || "normal").trim().toLowerCase();
|
||||
var normalized = String(value || "normal")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "compact" || normalized === "small" || normalized === "sm") return "compact";
|
||||
if (normalized === "big" || normalized === "large" || normalized === "lg") return "big";
|
||||
return "normal";
|
||||
@@ -35,7 +43,9 @@
|
||||
function commaList(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map(function (item) { return item.trim().toLowerCase(); })
|
||||
.map(function (item) {
|
||||
return item.trim().toLowerCase();
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -89,7 +99,8 @@
|
||||
requiredMessage: data.captchaRequiredMessage || "Please complete the security check.",
|
||||
incorrectMessage: data.captchaIncorrectMessage || "That answer was not correct. Try again.",
|
||||
expiredMessage: data.captchaExpiredMessage || "This challenge expired. Load a new one.",
|
||||
networkMessage: data.captchaNetworkMessage || "The verification service is unavailable. Try again.",
|
||||
networkMessage:
|
||||
data.captchaNetworkMessage || "The verification service is unavailable. Try again.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,15 +112,18 @@
|
||||
type: challenge && challenge.type ? challenge.type : state.config.type,
|
||||
action: state.config.action,
|
||||
disturbance: state.config.disturbance,
|
||||
imageStyle: challenge && challenge.metadata && challenge.metadata.imageStyle
|
||||
? challenge.metadata.imageStyle
|
||||
: state.config.imageStyle,
|
||||
requestedImageStyle: challenge && challenge.metadata && challenge.metadata.requestedImageStyle
|
||||
? challenge.metadata.requestedImageStyle
|
||||
: state.config.imageStyle,
|
||||
imageStylePool: challenge && challenge.metadata && challenge.metadata.imageStylePool
|
||||
? challenge.metadata.imageStylePool
|
||||
: state.config.allowedStyles,
|
||||
imageStyle:
|
||||
challenge && challenge.metadata && challenge.metadata.imageStyle
|
||||
? challenge.metadata.imageStyle
|
||||
: state.config.imageStyle,
|
||||
requestedImageStyle:
|
||||
challenge && challenge.metadata && challenge.metadata.requestedImageStyle
|
||||
? challenge.metadata.requestedImageStyle
|
||||
: state.config.imageStyle,
|
||||
imageStylePool:
|
||||
challenge && challenge.metadata && challenge.metadata.imageStylePool
|
||||
? challenge.metadata.imageStylePool
|
||||
: state.config.allowedStyles,
|
||||
size: state.config.size,
|
||||
status: state.status,
|
||||
challengeId: challenge && challenge.id ? challenge.id : "",
|
||||
@@ -190,7 +204,14 @@
|
||||
var answer = state.root.querySelector("[data-captcha-answer]");
|
||||
|
||||
show(loading, status === "loading");
|
||||
show(challenge, status !== "loading" && status !== "idle" && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha");
|
||||
show(
|
||||
challenge,
|
||||
status !== "loading" &&
|
||||
status !== "idle" &&
|
||||
state.config.provider !== "turnstile" &&
|
||||
state.config.provider !== "recaptcha" &&
|
||||
state.config.provider !== "hcaptcha",
|
||||
);
|
||||
show(badge, status === "verified");
|
||||
|
||||
if (state.config.showStatus) {
|
||||
@@ -201,16 +222,27 @@
|
||||
show(error, false);
|
||||
}
|
||||
|
||||
if (message && status === "verified") text(state.root, "[data-captcha-success-message]", message);
|
||||
if (message && status === "verified")
|
||||
text(state.root, "[data-captcha-success-message]", message);
|
||||
if (message && status !== "verified") text(state.root, "[data-captcha-error-message]", message);
|
||||
|
||||
if (answer) {
|
||||
answer.disabled = state.config.disabled || status === "verified" || status === "expired" || status === "loading" || status === "verifying";
|
||||
answer.disabled =
|
||||
state.config.disabled ||
|
||||
status === "verified" ||
|
||||
status === "expired" ||
|
||||
status === "loading" ||
|
||||
status === "verifying";
|
||||
answer.setAttribute("aria-invalid", status === "incorrect" ? "true" : "false");
|
||||
}
|
||||
|
||||
if (verifyButton) {
|
||||
verifyButton.disabled = state.config.disabled || status === "loading" || status === "verifying" || status === "verified" || status === "expired";
|
||||
verifyButton.disabled =
|
||||
state.config.disabled ||
|
||||
status === "loading" ||
|
||||
status === "verifying" ||
|
||||
status === "verified" ||
|
||||
status === "expired";
|
||||
}
|
||||
|
||||
show(verifyIcon, status !== "verifying");
|
||||
@@ -291,7 +323,12 @@
|
||||
honeypot.value = "";
|
||||
honeypot.name = "";
|
||||
}
|
||||
if (providerMount && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha") {
|
||||
if (
|
||||
providerMount &&
|
||||
state.config.provider !== "turnstile" &&
|
||||
state.config.provider !== "recaptcha" &&
|
||||
state.config.provider !== "hcaptcha"
|
||||
) {
|
||||
providerMount.replaceChildren();
|
||||
show(providerMount, false);
|
||||
}
|
||||
@@ -304,26 +341,52 @@
|
||||
var refresh = state.root.querySelector("[data-captcha-refresh]");
|
||||
var verify = state.root.querySelector("[data-captcha-verify]");
|
||||
var footer = state.root.querySelector("[data-captcha-footer]");
|
||||
var isExternal = state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha";
|
||||
var isExternal =
|
||||
state.config.provider === "turnstile" ||
|
||||
state.config.provider === "recaptcha" ||
|
||||
state.config.provider === "hcaptcha";
|
||||
var notRobot = isNotRobot(state);
|
||||
var locked = state.config.disabled || state.status === "loading" || state.status === "verifying" || state.notRobotPending;
|
||||
var locked =
|
||||
state.config.disabled ||
|
||||
state.status === "loading" ||
|
||||
state.status === "verifying" ||
|
||||
state.notRobotPending;
|
||||
|
||||
show(footer, !notRobot);
|
||||
show(audio, !notRobot && state.config.showAudio && state.config.showListen && Boolean(challenge && challenge.audioUrl) && state.status !== "verified");
|
||||
show(audioAlternative, !notRobot && state.config.showAudio && Boolean(challenge && challenge.type === "image") && state.status !== "verified");
|
||||
show(
|
||||
audio,
|
||||
!notRobot &&
|
||||
state.config.showAudio &&
|
||||
state.config.showListen &&
|
||||
Boolean(challenge && challenge.audioUrl) &&
|
||||
state.status !== "verified",
|
||||
);
|
||||
show(
|
||||
audioAlternative,
|
||||
!notRobot &&
|
||||
state.config.showAudio &&
|
||||
Boolean(challenge && challenge.type === "image") &&
|
||||
state.status !== "verified",
|
||||
);
|
||||
show(refresh, !notRobot && state.config.showRefresh && state.status !== "verified");
|
||||
show(verify, !notRobot && state.config.showVerify && !isExternal);
|
||||
|
||||
if (audio) audio.disabled = locked;
|
||||
if (audioAlternative) audioAlternative.disabled = locked;
|
||||
if (refresh) refresh.disabled = locked;
|
||||
if (verify) verify.disabled = locked || state.status === "verified" || state.status === "expired";
|
||||
if (verify)
|
||||
verify.disabled = locked || state.status === "verified" || state.status === "expired";
|
||||
}
|
||||
|
||||
function renderItems(state, challenge) {
|
||||
var container = state.root.querySelector("[data-captcha-items]");
|
||||
var template = state.root.querySelector("[data-captcha-item-template]");
|
||||
if (!container || !template || !Array.isArray(challenge.items) || challenge.items.length === 0) {
|
||||
if (
|
||||
!container ||
|
||||
!template ||
|
||||
!Array.isArray(challenge.items) ||
|
||||
challenge.items.length === 0
|
||||
) {
|
||||
show(container, false);
|
||||
return;
|
||||
}
|
||||
@@ -359,10 +422,14 @@
|
||||
if (selected) {
|
||||
state.selections.splice(index, 1);
|
||||
} else {
|
||||
var maximum = Number(state.challenge && state.challenge.maxSelections ? state.challenge.maxSelections : 0);
|
||||
var maximum = Number(
|
||||
state.challenge && state.challenge.maxSelections ? state.challenge.maxSelections : 0,
|
||||
);
|
||||
if (maximum > 0 && state.selections.length >= maximum) {
|
||||
var removed = state.selections.shift();
|
||||
var previousButton = state.root.querySelector('[data-captcha-item-id="' + CSS.escape(removed) + '"]');
|
||||
var previousButton = state.root.querySelector(
|
||||
'[data-captcha-item-id="' + CSS.escape(removed) + '"]',
|
||||
);
|
||||
if (previousButton) {
|
||||
previousButton.setAttribute("aria-pressed", "false");
|
||||
show(previousButton.querySelector("[data-captcha-item-check]"), false);
|
||||
@@ -376,7 +443,9 @@
|
||||
show(button.querySelector("[data-captcha-item-check]"), nowSelected);
|
||||
emit(state, "input", { selections: state.selections.slice() });
|
||||
|
||||
var minimum = Number(state.challenge && state.challenge.minSelections ? state.challenge.minSelections : 0);
|
||||
var minimum = Number(
|
||||
state.challenge && state.challenge.minSelections ? state.challenge.minSelections : 0,
|
||||
);
|
||||
if (state.config.autoVerify && minimum > 0 && state.selections.length >= minimum) verify(state);
|
||||
}
|
||||
|
||||
@@ -389,10 +458,15 @@
|
||||
|
||||
var notRobot = challenge.type === "not-robot";
|
||||
state.root.dataset.captchaType = challenge.type || state.config.type;
|
||||
state.root.dataset.captchaResolvedImageStyle = challenge.metadata && challenge.metadata.imageStyle
|
||||
? String(challenge.metadata.imageStyle)
|
||||
: state.config.imageStyle;
|
||||
text(state.root, "[data-captcha-prompt]", challenge.prompt || "Complete the security challenge.");
|
||||
state.root.dataset.captchaResolvedImageStyle =
|
||||
challenge.metadata && challenge.metadata.imageStyle
|
||||
? String(challenge.metadata.imageStyle)
|
||||
: state.config.imageStyle;
|
||||
text(
|
||||
state.root,
|
||||
"[data-captcha-prompt]",
|
||||
challenge.prompt || "Complete the security challenge.",
|
||||
);
|
||||
|
||||
var promptRow = state.root.querySelector("[data-captcha-prompt-row]");
|
||||
var notRobotPanel = state.root.querySelector("[data-captcha-not-robot]");
|
||||
@@ -435,7 +509,12 @@
|
||||
emit(state, "challenge", challenge);
|
||||
emit(state, "ready");
|
||||
|
||||
if (state.config.autoVerify && !notRobot && challenge.inputMode === "none" && !challenge.items) {
|
||||
if (
|
||||
state.config.autoVerify &&
|
||||
!notRobot &&
|
||||
challenge.inputMode === "none" &&
|
||||
!challenge.items
|
||||
) {
|
||||
window.setTimeout(function () {
|
||||
verify(state);
|
||||
}, 0);
|
||||
@@ -445,7 +524,11 @@
|
||||
async function createChallenge(state, requestedPresentation) {
|
||||
if (state.config.disabled) return;
|
||||
|
||||
if (state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha") {
|
||||
if (
|
||||
state.config.provider === "turnstile" ||
|
||||
state.config.provider === "recaptcha" ||
|
||||
state.config.provider === "hcaptcha"
|
||||
) {
|
||||
await mountExternal(state);
|
||||
return;
|
||||
}
|
||||
@@ -495,7 +578,13 @@
|
||||
|
||||
async function verify(state) {
|
||||
state.notRobotPending = false;
|
||||
if (state.config.disabled || state.status === "loading" || state.status === "verifying" || state.status === "expired" || state.status === "verified") {
|
||||
if (
|
||||
state.config.disabled ||
|
||||
state.status === "loading" ||
|
||||
state.status === "verifying" ||
|
||||
state.status === "expired" ||
|
||||
state.status === "verified"
|
||||
) {
|
||||
updateNotRobotState(state);
|
||||
updateControls(state);
|
||||
return;
|
||||
@@ -636,7 +725,13 @@
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
script.addEventListener("error", function () { reject(new Error("CAPTCHA provider script failed")); }, { once: true });
|
||||
script.addEventListener(
|
||||
"error",
|
||||
function () {
|
||||
reject(new Error("CAPTCHA provider script failed"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
scriptPromises.set(definition.url, promise);
|
||||
@@ -674,7 +769,12 @@
|
||||
state.externalWidgetId = api.render(mount, {
|
||||
sitekey: state.config.siteKey,
|
||||
theme: "auto",
|
||||
size: state.config.size === "compact" ? "compact" : state.config.provider === "turnstile" ? "flexible" : "normal",
|
||||
size:
|
||||
state.config.size === "compact"
|
||||
? "compact"
|
||||
: state.config.provider === "turnstile"
|
||||
? "flexible"
|
||||
: "normal",
|
||||
action: state.config.action,
|
||||
callback: function (token) {
|
||||
setResponseToken(state, token);
|
||||
@@ -715,36 +815,55 @@
|
||||
audio.volume = 1;
|
||||
audio.src = new URL(state.challenge.audioUrl, window.location.href).href;
|
||||
|
||||
audio.addEventListener("ended", function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
emit(state, "audioEnd");
|
||||
emit(state, "audio-end");
|
||||
}, { once: true });
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
emit(state, "audioEnd");
|
||||
emit(state, "audio-end");
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
audio.addEventListener("error", function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
var mediaError = audio.error;
|
||||
setStatus(state, "network-error", "The audio challenge could not be played. Load a new challenge and try again.");
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-failed",
|
||||
audioUrl: audio.src,
|
||||
mediaErrorCode: mediaError ? mediaError.code : null,
|
||||
});
|
||||
}, { once: true });
|
||||
audio.addEventListener(
|
||||
"error",
|
||||
function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
var mediaError = audio.error;
|
||||
setStatus(
|
||||
state,
|
||||
"network-error",
|
||||
"The audio challenge could not be played. Load a new challenge and try again.",
|
||||
);
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-failed",
|
||||
audioUrl: audio.src,
|
||||
mediaErrorCode: mediaError ? mediaError.code : null,
|
||||
});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
audio.load();
|
||||
audio.play().then(function () {
|
||||
emit(state, "audioStart");
|
||||
emit(state, "audio-start");
|
||||
}).catch(function (error) {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
setStatus(state, "network-error", "The audio challenge could not be played. Check the audio endpoint and try again.");
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-rejected",
|
||||
audioUrl: audio.src,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
audio
|
||||
.play()
|
||||
.then(function () {
|
||||
emit(state, "audioStart");
|
||||
emit(state, "audio-start");
|
||||
})
|
||||
.catch(function (error) {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
setStatus(
|
||||
state,
|
||||
"network-error",
|
||||
"The audio challenge could not be played. Check the audio endpoint and try again.",
|
||||
);
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-rejected",
|
||||
audioUrl: audio.src,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onFormSubmit(state, event) {
|
||||
@@ -753,7 +872,10 @@
|
||||
event.stopImmediatePropagation();
|
||||
setStatus(state, "incorrect", state.config.requiredMessage);
|
||||
emit(state, "failure", { code: "missing-input" });
|
||||
var target = state.root.querySelector("[data-captcha-answer]") || state.root.querySelector("[data-captcha-not-robot-button]") || state.root.querySelector("[data-captcha-verify]");
|
||||
var target =
|
||||
state.root.querySelector("[data-captcha-answer]") ||
|
||||
state.root.querySelector("[data-captcha-not-robot-button]") ||
|
||||
state.root.querySelector("[data-captcha-verify]");
|
||||
if (target && typeof target.focus === "function") target.focus();
|
||||
}
|
||||
|
||||
@@ -781,6 +903,8 @@
|
||||
externalWidgetId: null,
|
||||
audioPlayer: null,
|
||||
form: root.closest("form"),
|
||||
formSubmitListener: null,
|
||||
formSuccessListener: null,
|
||||
};
|
||||
states.set(root, state);
|
||||
root.dataset.captchaSize = state.config.size;
|
||||
@@ -808,17 +932,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (verifyButton) verifyButton.addEventListener("click", function () { verify(state); });
|
||||
if (notRobotButton) notRobotButton.addEventListener("click", function () { verifyNotRobot(state); });
|
||||
if (refreshButton) refreshButton.addEventListener("click", function () {
|
||||
emit(state, "refresh");
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
if (audioButton) audioButton.addEventListener("click", function () { playAudio(state); });
|
||||
if (audioAlternative) audioAlternative.addEventListener("click", function () {
|
||||
emit(state, "refresh", { presentation: "audio" });
|
||||
createChallenge(state, "audio");
|
||||
});
|
||||
if (verifyButton)
|
||||
verifyButton.addEventListener("click", function () {
|
||||
verify(state);
|
||||
});
|
||||
if (notRobotButton)
|
||||
notRobotButton.addEventListener("click", function () {
|
||||
verifyNotRobot(state);
|
||||
});
|
||||
if (refreshButton)
|
||||
refreshButton.addEventListener("click", function () {
|
||||
emit(state, "refresh");
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
if (audioButton)
|
||||
audioButton.addEventListener("click", function () {
|
||||
playAudio(state);
|
||||
});
|
||||
if (audioAlternative)
|
||||
audioAlternative.addEventListener("click", function () {
|
||||
emit(state, "refresh", { presentation: "audio" });
|
||||
createChallenge(state, "audio");
|
||||
});
|
||||
|
||||
root.addEventListener("captcha-reset", function () {
|
||||
emit(state, "refresh");
|
||||
@@ -826,12 +961,14 @@
|
||||
});
|
||||
|
||||
if (state.form) {
|
||||
state.form.addEventListener("submit", function (event) {
|
||||
state.formSubmitListener = function (event) {
|
||||
onFormSubmit(state, event);
|
||||
}, true);
|
||||
state.form.addEventListener("wire:success", function () {
|
||||
};
|
||||
state.formSuccessListener = function () {
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
};
|
||||
state.form.addEventListener("submit", state.formSubmitListener, true);
|
||||
state.form.addEventListener("wire:success", state.formSuccessListener);
|
||||
}
|
||||
|
||||
updateNotRobotState(state);
|
||||
@@ -846,8 +983,53 @@
|
||||
host.querySelectorAll("[data-wrn-captcha]").forEach(initialize);
|
||||
}
|
||||
|
||||
function cleanupRoot(root) {
|
||||
var state = states.get(root);
|
||||
if (!state) return;
|
||||
if (state.timer) clearInterval(state.timer);
|
||||
if (state.notRobotTimer) clearTimeout(state.notRobotTimer);
|
||||
if (state.audioPlayer) {
|
||||
try {
|
||||
state.audioPlayer.pause();
|
||||
state.audioPlayer.src = "";
|
||||
} catch {
|
||||
// Best-effort cleanup for browser audio implementations.
|
||||
}
|
||||
}
|
||||
if (state.form && state.formSubmitListener) {
|
||||
state.form.removeEventListener("submit", state.formSubmitListener, true);
|
||||
}
|
||||
if (state.form && state.formSuccessListener) {
|
||||
state.form.removeEventListener("wire:success", state.formSuccessListener);
|
||||
}
|
||||
try {
|
||||
if (state.externalApi && state.externalWidgetId != null) {
|
||||
if (typeof state.externalApi.remove === "function")
|
||||
state.externalApi.remove(state.externalWidgetId);
|
||||
else if (typeof state.externalApi.reset === "function")
|
||||
state.externalApi.reset(state.externalWidgetId);
|
||||
}
|
||||
} catch {
|
||||
// Third-party providers may reject cleanup after navigation.
|
||||
}
|
||||
states.delete(root);
|
||||
}
|
||||
|
||||
function unmount(scope) {
|
||||
var host = scope || document;
|
||||
var roots = [];
|
||||
if (host instanceof Element && host.matches("[data-wrn-captcha]")) roots.push(host);
|
||||
if (host.querySelectorAll)
|
||||
host.querySelectorAll("[data-wrn-captcha]").forEach(function (root) {
|
||||
roots.push(root);
|
||||
});
|
||||
roots.forEach(cleanupRoot);
|
||||
}
|
||||
|
||||
var runtime = {
|
||||
scan: scan,
|
||||
mount: scan,
|
||||
unmount: unmount,
|
||||
reset: function (element) {
|
||||
var root = typeof element === "string" ? document.querySelector(element) : element;
|
||||
var state = root ? states.get(root) : null;
|
||||
@@ -861,9 +1043,17 @@
|
||||
};
|
||||
|
||||
window[RUNTIME_KEY] = runtime;
|
||||
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
|
||||
window.__wrnexusRuntimes.captcha = { mount: scan, unmount: unmount };
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () { scan(document); }, { once: true });
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
function () {
|
||||
scan(document);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
} else {
|
||||
scan(document);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ component Captcha {
|
||||
{...attrs}
|
||||
id='{id}'
|
||||
data-wrn-captcha
|
||||
data-wrnexus-runtime="captcha"
|
||||
data-captcha-provider='{provider}'
|
||||
data-captcha-site-key='{siteKey}'
|
||||
data-captcha-type='{type}'
|
||||
@@ -96,12 +97,6 @@ component Captcha {
|
||||
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}'
|
||||
>
|
||||
<script
|
||||
src="/assets/wrnexus/captcha.js"
|
||||
defer
|
||||
data-wrn-captcha-runtime
|
||||
></script>
|
||||
|
||||
<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'>
|
||||
<div class='min-w-0'>
|
||||
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -49,5 +49,12 @@
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.2",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./src/plugin.ts",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,10 +126,7 @@ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
|
||||
|
||||
try {
|
||||
const packageEntry = createRequire(import.meta.url).resolve("@wrnexus/captcha/audio");
|
||||
addCandidate(
|
||||
candidates,
|
||||
join(dirname(dirname(dirname(packageEntry))), "assets", "audio"),
|
||||
);
|
||||
addCandidate(candidates, join(dirname(dirname(dirname(packageEntry))), "assets", "audio"));
|
||||
} catch {
|
||||
// The source-relative and cwd fallbacks below still support direct source use.
|
||||
}
|
||||
@@ -140,7 +137,10 @@ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
|
||||
let current = process.cwd();
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
addCandidate(candidates, join(current, "packages", "captcha", "assets", "audio"));
|
||||
addCandidate(candidates, join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"));
|
||||
addCandidate(
|
||||
candidates,
|
||||
join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"),
|
||||
);
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
@@ -190,9 +190,7 @@ export class AssetAudioRenderer implements CaptchaAudioRenderer {
|
||||
}
|
||||
if (!sequence.length) throw new Error("Cannot render an empty CAPTCHA audio sequence");
|
||||
|
||||
const clips = await Promise.all(
|
||||
sequence.map((token) => this.load(language, safeToken(token))),
|
||||
);
|
||||
const clips = await Promise.all(sequence.map((token) => this.load(language, safeToken(token))));
|
||||
const first = clips[0]!;
|
||||
|
||||
for (const clip of clips) {
|
||||
@@ -223,17 +221,13 @@ export class AssetAudioRenderer implements CaptchaAudioRenderer {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const bytes = new Uint8Array(
|
||||
await readFile(join(this.assetsDir, language, `${token}.wav`)),
|
||||
);
|
||||
const bytes = new Uint8Array(await readFile(join(this.assetsDir, language, `${token}.wav`)));
|
||||
const parsed = parseWav(bytes);
|
||||
this.cache.set(key, parsed);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAssetAudioRenderer(
|
||||
options?: AssetAudioRendererOptions,
|
||||
): AssetAudioRenderer {
|
||||
export function createAssetAudioRenderer(options?: AssetAudioRendererOptions): AssetAudioRenderer {
|
||||
return new AssetAudioRenderer(options);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,13 @@ export class CalculationCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
answerKind: "text",
|
||||
image: renderTextChallenge(expression, context),
|
||||
inputMode: "numeric",
|
||||
audioSequence: ["what", "is", ...numberTokens(left), ...(OPERATOR_WORDS[operator] ?? []), ...numberTokens(right)],
|
||||
audioSequence: [
|
||||
"what",
|
||||
"is",
|
||||
...numberTokens(left),
|
||||
...(OPERATOR_WORDS[operator] ?? []),
|
||||
...numberTokens(right),
|
||||
],
|
||||
metadata: { operator, imageStyle: context.imageStyle },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,15 @@ import type {
|
||||
CaptchaImageItem,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
import { createImage, drawLine, fillCircle, fillPolygon, fillRect, pngDataUri, setPixel } from "./png.ts";
|
||||
import {
|
||||
createImage,
|
||||
drawLine,
|
||||
fillCircle,
|
||||
fillPolygon,
|
||||
fillRect,
|
||||
pngDataUri,
|
||||
setPixel,
|
||||
} from "./png.ts";
|
||||
|
||||
const SHAPES = ["circle", "square", "triangle", "diamond", "star"] as const;
|
||||
type Shape = (typeof SHAPES)[number];
|
||||
@@ -39,9 +47,26 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
if (shape === "circle") fillCircle(image, cx, cy, size, color);
|
||||
else if (shape === "square") fillRect(image, cx - size, cy - size, size * 2, size * 2, color);
|
||||
else if (shape === "triangle") {
|
||||
fillPolygon(image, [[cx, cy - size], [cx - size, cy + size], [cx + size, cy + size]], color);
|
||||
fillPolygon(
|
||||
image,
|
||||
[
|
||||
[cx, cy - size],
|
||||
[cx - size, cy + size],
|
||||
[cx + size, cy + size],
|
||||
],
|
||||
color,
|
||||
);
|
||||
} else if (shape === "diamond") {
|
||||
fillPolygon(image, [[cx, cy - size], [cx - size, cy], [cx, cy + size], [cx + size, cy]], color);
|
||||
fillPolygon(
|
||||
image,
|
||||
[
|
||||
[cx, cy - size],
|
||||
[cx - size, cy],
|
||||
[cx, cy + size],
|
||||
[cx + size, cy],
|
||||
],
|
||||
color,
|
||||
);
|
||||
} else {
|
||||
fillPolygon(image, starPoints(cx, cy, size, size * 0.45), color);
|
||||
}
|
||||
@@ -49,12 +74,12 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const dots = Math.round(35 + ratio * 150);
|
||||
for (let index = 0; index < dots; index++) {
|
||||
setPixel(
|
||||
image,
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
[context.randomInt(105, 225), context.randomInt(105, 225), context.randomInt(105, 225), Math.round(55 + ratio * 65)],
|
||||
);
|
||||
setPixel(image, context.randomInt(0, 95), context.randomInt(0, 71), [
|
||||
context.randomInt(105, 225),
|
||||
context.randomInt(105, 225),
|
||||
context.randomInt(105, 225),
|
||||
Math.round(55 + ratio * 65),
|
||||
]);
|
||||
}
|
||||
|
||||
const lines = Math.round(1 + ratio * 4);
|
||||
@@ -65,7 +90,12 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
context.randomInt(0, 71),
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
[context.randomInt(100, 210), context.randomInt(100, 210), context.randomInt(100, 210), Math.round(45 + ratio * 55)],
|
||||
[
|
||||
context.randomInt(100, 210),
|
||||
context.randomInt(100, 210),
|
||||
context.randomInt(100, 210),
|
||||
Math.round(45 + ratio * 55),
|
||||
],
|
||||
ratio > 0.75 ? 2 : 1,
|
||||
);
|
||||
}
|
||||
@@ -105,7 +135,11 @@ export class ImageCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
}
|
||||
|
||||
shuffle(entries, context);
|
||||
const answer = entries.filter((entry) => entry.shape === target).map((entry) => entry.id).sort().join(",");
|
||||
const answer = entries
|
||||
.filter((entry) => entry.shape === target)
|
||||
.map((entry) => entry.id)
|
||||
.sort()
|
||||
.join(",");
|
||||
return {
|
||||
type: "image",
|
||||
presentation: "visual",
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { CaptchaChallengeGenerator } from "../types.ts";
|
||||
import { alphaCaptchaGenerator, alphanumericCaptchaGenerator, numberCaptchaGenerator } from "./text.ts";
|
||||
import {
|
||||
alphaCaptchaGenerator,
|
||||
alphanumericCaptchaGenerator,
|
||||
numberCaptchaGenerator,
|
||||
} from "./text.ts";
|
||||
import { calculationCaptchaGenerator } from "./calculation.ts";
|
||||
import { imageCaptchaGenerator } from "./image.ts";
|
||||
import { honeypotCaptchaGenerator, notRobotCaptchaGenerator, timingCaptchaGenerator } from "./invisible.ts";
|
||||
import {
|
||||
honeypotCaptchaGenerator,
|
||||
notRobotCaptchaGenerator,
|
||||
timingCaptchaGenerator,
|
||||
} from "./invisible.ts";
|
||||
|
||||
export * from "./text.ts";
|
||||
export * from "./calculation.ts";
|
||||
@@ -13,7 +21,8 @@ export * from "./styles.ts";
|
||||
|
||||
export function defineCaptchaGenerator<T extends CaptchaChallengeGenerator>(generator: T): T {
|
||||
if (!generator.type) throw new TypeError("CAPTCHA generator requires a stable type");
|
||||
if (typeof generator.generate !== "function") throw new TypeError("CAPTCHA generator requires generate()");
|
||||
if (typeof generator.generate !== "function")
|
||||
throw new TypeError("CAPTCHA generator requires generate()");
|
||||
return generator;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ export class InvisibleCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
return {
|
||||
type: this.type,
|
||||
presentation: "invisible",
|
||||
prompt: this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
|
||||
prompt:
|
||||
this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
|
||||
answer: JSON.stringify({ honeypot: "", timingToken }),
|
||||
answerKind: "invisible",
|
||||
inputMode: "none",
|
||||
|
||||
@@ -8,7 +8,11 @@ export interface RgbaImage {
|
||||
|
||||
export type Rgba = readonly [number, number, number, number?];
|
||||
|
||||
export function createImage(width: number, height: number, background: Rgba = [255, 255, 255, 255]): RgbaImage {
|
||||
export function createImage(
|
||||
width: number,
|
||||
height: number,
|
||||
background: Rgba = [255, 255, 255, 255],
|
||||
): RgbaImage {
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
const alpha = background[3] ?? 255;
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
@@ -33,13 +37,28 @@ export function setPixel(image: RgbaImage, x: number, y: number, color: Rgba): v
|
||||
image.data[index + 3] = 255;
|
||||
}
|
||||
|
||||
export function fillRect(image: RgbaImage, x: number, y: number, width: number, height: number, color: Rgba): void {
|
||||
export function fillRect(
|
||||
image: RgbaImage,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: Rgba,
|
||||
): void {
|
||||
for (let py = Math.floor(y); py < Math.ceil(y + height); py++) {
|
||||
for (let px = Math.floor(x); px < Math.ceil(x + width); px++) setPixel(image, px, py, color);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y1: number, color: Rgba, thickness = 1): void {
|
||||
export function drawLine(
|
||||
image: RgbaImage,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
color: Rgba,
|
||||
thickness = 1,
|
||||
): void {
|
||||
let x = Math.round(x0);
|
||||
let y = Math.round(y0);
|
||||
const targetX = Math.round(x1);
|
||||
@@ -50,7 +69,14 @@ export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y
|
||||
const sy = y < targetY ? 1 : -1;
|
||||
let error = dx + dy;
|
||||
while (true) {
|
||||
fillRect(image, x - Math.floor(thickness / 2), y - Math.floor(thickness / 2), thickness, thickness, color);
|
||||
fillRect(
|
||||
image,
|
||||
x - Math.floor(thickness / 2),
|
||||
y - Math.floor(thickness / 2),
|
||||
thickness,
|
||||
thickness,
|
||||
color,
|
||||
);
|
||||
if (x === targetX && y === targetY) break;
|
||||
const twice = 2 * error;
|
||||
if (twice >= dy) {
|
||||
@@ -64,7 +90,13 @@ export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y
|
||||
}
|
||||
}
|
||||
|
||||
export function fillCircle(image: RgbaImage, centerX: number, centerY: number, radius: number, color: Rgba): void {
|
||||
export function fillCircle(
|
||||
image: RgbaImage,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number,
|
||||
color: Rgba,
|
||||
): void {
|
||||
const r2 = radius * radius;
|
||||
for (let y = Math.floor(centerY - radius); y <= Math.ceil(centerY + radius); y++) {
|
||||
for (let x = Math.floor(centerX - radius); x <= Math.ceil(centerX + radius); x++) {
|
||||
@@ -75,7 +107,11 @@ export function fillCircle(image: RgbaImage, centerX: number, centerY: number, r
|
||||
}
|
||||
}
|
||||
|
||||
function pointInPolygon(x: number, y: number, points: readonly (readonly [number, number])[]): boolean {
|
||||
function pointInPolygon(
|
||||
x: number,
|
||||
y: number,
|
||||
points: readonly (readonly [number, number])[],
|
||||
): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const xi = points[i]![0];
|
||||
@@ -88,13 +124,18 @@ function pointInPolygon(x: number, y: number, points: readonly (readonly [number
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function fillPolygon(image: RgbaImage, points: readonly (readonly [number, number])[], color: Rgba): void {
|
||||
export function fillPolygon(
|
||||
image: RgbaImage,
|
||||
points: readonly (readonly [number, number])[],
|
||||
color: Rgba,
|
||||
): void {
|
||||
const minX = Math.floor(Math.min(...points.map((point) => point[0])));
|
||||
const maxX = Math.ceil(Math.max(...points.map((point) => point[0])));
|
||||
const minY = Math.floor(Math.min(...points.map((point) => point[1])));
|
||||
const maxY = Math.ceil(Math.max(...points.map((point) => point[1])));
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let x = minX; x <= maxX; x++) if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
|
||||
for (let x = minX; x <= maxX; x++)
|
||||
if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +175,15 @@ export function drawText(
|
||||
let cursor = options.x;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const jitter = options.jitter?.(index) ?? { x: 0, y: 0, shear: 0 };
|
||||
drawGlyph(image, text[index]!, cursor + jitter.x, options.y + jitter.y, options.scale, options.color, jitter.shear);
|
||||
drawGlyph(
|
||||
image,
|
||||
text[index]!,
|
||||
cursor + jitter.x,
|
||||
options.y + jitter.y,
|
||||
options.scale,
|
||||
options.color,
|
||||
jitter.shear,
|
||||
);
|
||||
cursor += options.scale * 5 + spacing;
|
||||
}
|
||||
}
|
||||
@@ -170,7 +219,12 @@ function adler32(bytes: Uint8Array): number {
|
||||
}
|
||||
|
||||
function u32(value: number): Uint8Array {
|
||||
return Uint8Array.of((value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255);
|
||||
return Uint8Array.of(
|
||||
(value >>> 24) & 255,
|
||||
(value >>> 16) & 255,
|
||||
(value >>> 8) & 255,
|
||||
value & 255,
|
||||
);
|
||||
}
|
||||
|
||||
function concat(parts: readonly Uint8Array[]): Uint8Array {
|
||||
@@ -195,9 +249,15 @@ function deflateStored(data: Uint8Array): Uint8Array {
|
||||
const size = Math.min(65535, data.length - offset);
|
||||
const final = offset + size >= data.length;
|
||||
const length = size;
|
||||
const inverse = (~length) & 0xffff;
|
||||
const inverse = ~length & 0xffff;
|
||||
blocks.push(
|
||||
Uint8Array.of(final ? 1 : 0, length & 255, (length >>> 8) & 255, inverse & 255, (inverse >>> 8) & 255),
|
||||
Uint8Array.of(
|
||||
final ? 1 : 0,
|
||||
length & 255,
|
||||
(length >>> 8) & 255,
|
||||
inverse & 255,
|
||||
(inverse >>> 8) & 255,
|
||||
),
|
||||
data.slice(offset, offset + size),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CaptchaConcreteImageStyle,
|
||||
CaptchaImageStyle,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaConcreteImageStyle, CaptchaImageStyle } from "../types.ts";
|
||||
|
||||
export const CAPTCHA_CONCRETE_IMAGE_STYLES = [
|
||||
"classic",
|
||||
@@ -52,9 +49,7 @@ export function normalizeCaptchaImageStyle(
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!STYLE_SET.has(normalized)) {
|
||||
throw new RangeError(
|
||||
`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`,
|
||||
);
|
||||
throw new RangeError(`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`);
|
||||
}
|
||||
return normalized as CaptchaImageStyle;
|
||||
}
|
||||
@@ -67,9 +62,7 @@ export function normalizeCaptchaImageStyleList(
|
||||
for (const entry of styleValues(value)) {
|
||||
if (entry === "random") continue;
|
||||
if (!CONCRETE_STYLE_SET.has(entry)) {
|
||||
throw new RangeError(
|
||||
`${name} contains an unknown image style: ${entry}`,
|
||||
);
|
||||
throw new RangeError(`${name} contains an unknown image style: ${entry}`);
|
||||
}
|
||||
const style = entry as CaptchaConcreteImageStyle;
|
||||
if (!styles.includes(style)) styles.push(style);
|
||||
@@ -95,17 +88,12 @@ export function resolveCaptchaImageStyle(
|
||||
options: ResolveCaptchaImageStyleOptions,
|
||||
): ResolvedCaptchaImageStyle {
|
||||
const requested = normalizeCaptchaImageStyle(options.imageStyle, "random");
|
||||
const allowed = normalizeCaptchaImageStyleList(
|
||||
options.allowedStyles,
|
||||
"allowedStyles",
|
||||
);
|
||||
const allowed = normalizeCaptchaImageStyleList(options.allowedStyles, "allowedStyles");
|
||||
const excluded = new Set(
|
||||
normalizeCaptchaImageStyleList(options.excludedStyles, "excludedStyles"),
|
||||
);
|
||||
|
||||
const source = allowed.length
|
||||
? allowed
|
||||
: [...CAPTCHA_CONCRETE_IMAGE_STYLES];
|
||||
const source = allowed.length ? allowed : [...CAPTCHA_CONCRETE_IMAGE_STYLES];
|
||||
const pool = source.filter((style) => !excluded.has(style));
|
||||
|
||||
if (!pool.length) {
|
||||
@@ -116,9 +104,7 @@ export function resolveCaptchaImageStyle(
|
||||
|
||||
if (!options.randomizeStyle && requested !== "random") {
|
||||
if (!pool.includes(requested)) {
|
||||
throw new RangeError(
|
||||
`imageStyle ${requested} is not available in the configured style pool`,
|
||||
);
|
||||
throw new RangeError(`imageStyle ${requested} is not available in the configured style pool`);
|
||||
}
|
||||
return { requested, resolved: requested, pool };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ const ALPHA = "ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||
const ALPHANUMERIC = `${ALPHA}${NUMBERS}`;
|
||||
|
||||
function defaultLength(context: CaptchaGeneratorContext): number {
|
||||
return context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6);
|
||||
return (
|
||||
context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6)
|
||||
);
|
||||
}
|
||||
|
||||
function charset(type: CaptchaChallengeType): string {
|
||||
@@ -20,11 +22,15 @@ function charset(type: CaptchaChallengeType): string {
|
||||
return ALPHANUMERIC;
|
||||
}
|
||||
|
||||
function generateText(type: "number" | "alpha" | "alphanumeric", context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
function generateText(
|
||||
type: "number" | "alpha" | "alphanumeric",
|
||||
context: CaptchaGeneratorContext,
|
||||
): GeneratedCaptchaChallenge {
|
||||
const source = charset(type);
|
||||
const length = Math.max(3, Math.min(10, defaultLength(context)));
|
||||
let answer = "";
|
||||
for (let index = 0; index < length; index++) answer += source[context.randomInt(0, source.length - 1)];
|
||||
for (let index = 0; index < length; index++)
|
||||
answer += source[context.randomInt(0, source.length - 1)];
|
||||
return {
|
||||
type,
|
||||
presentation: "visual",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CaptchaConcreteImageStyle,
|
||||
CaptchaGeneratorContext,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaConcreteImageStyle, CaptchaGeneratorContext } from "../types.ts";
|
||||
import {
|
||||
createImage,
|
||||
drawGlyph,
|
||||
@@ -56,7 +53,11 @@ function randomColor(
|
||||
];
|
||||
}
|
||||
|
||||
function rawPixel(image: RgbaImage, x: number, y: number): readonly [number, number, number, number] {
|
||||
function rawPixel(
|
||||
image: RgbaImage,
|
||||
x: number,
|
||||
y: number,
|
||||
): readonly [number, number, number, number] {
|
||||
const px = clamp(Math.round(x), 0, image.width - 1);
|
||||
const py = clamp(Math.round(y), 0, image.height - 1);
|
||||
const index = (py * image.width + px) * 4;
|
||||
@@ -98,11 +99,12 @@ function layoutFor(text: string, style: CaptchaConcreteImageStyle): TextLayout {
|
||||
const scale = text.length > 9 ? 3 : text.length > 7 ? 4 : 5;
|
||||
const glyphWidth = scale * 5;
|
||||
const normalSpacing = scale + 3;
|
||||
const spacing = style === "collision"
|
||||
? Math.max(-Math.floor(scale * 0.45), -2)
|
||||
: style === "cross-shadow"
|
||||
? scale
|
||||
: normalSpacing;
|
||||
const spacing =
|
||||
style === "collision"
|
||||
? Math.max(-Math.floor(scale * 0.45), -2)
|
||||
: style === "cross-shadow"
|
||||
? scale
|
||||
: normalSpacing;
|
||||
const textWidth = text.length * glyphWidth + Math.max(0, text.length - 1) * spacing;
|
||||
const textHeight = scale * 7;
|
||||
return {
|
||||
@@ -218,9 +220,8 @@ function drawCharacters(
|
||||
const y = layout.startY + context.randomInt(-jitterY, jitterY);
|
||||
const glyphShear = (context.randomFloat() - 0.5) * shear;
|
||||
const accent = ACCENTS[context.randomInt(0, ACCENTS.length - 1)]!;
|
||||
const color = style === "collision" || style === "cross-shadow" || style === "pixel"
|
||||
? accent
|
||||
: INK;
|
||||
const color =
|
||||
style === "collision" || style === "cross-shadow" || style === "pixel" ? accent : INK;
|
||||
|
||||
if (style === "cross-shadow") {
|
||||
drawGlyph(image, character, x - 3, y + 3, layout.scale, [37, 99, 235, 95], glyphShear);
|
||||
@@ -228,7 +229,15 @@ function drawCharacters(
|
||||
} else if (style === "collision") {
|
||||
drawGlyph(image, character, x - 2, y + 2, layout.scale, [15, 23, 42, 75], glyphShear);
|
||||
if (index > 0 && context.randomFloat() < 0.65) {
|
||||
drawGlyph(image, character, x + context.randomInt(-4, 1), y, layout.scale, [2, 6, 23, 60], -glyphShear);
|
||||
drawGlyph(
|
||||
image,
|
||||
character,
|
||||
x + context.randomInt(-4, 1),
|
||||
y,
|
||||
layout.scale,
|
||||
[2, 6, 23, 60],
|
||||
-glyphShear,
|
||||
);
|
||||
}
|
||||
} else if (context.randomFloat() < 0.28 + ratio * 0.35) {
|
||||
drawGlyph(image, character, x + 1, y + 1, layout.scale, [15, 23, 42, 70], glyphShear);
|
||||
@@ -283,13 +292,25 @@ function shiftColumns(
|
||||
|
||||
function applyClassic(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addCrossingLines(image, context, Math.round(3 + ratio * 5), Math.round(55 + ratio * 55), ratio > 0.7 ? 2 : 1);
|
||||
addCrossingLines(
|
||||
image,
|
||||
context,
|
||||
Math.round(3 + ratio * 5),
|
||||
Math.round(55 + ratio * 55),
|
||||
ratio > 0.7 ? 2 : 1,
|
||||
);
|
||||
addDots(image, context, Math.round(140 + ratio * 470), Math.round(45 + ratio * 50));
|
||||
}
|
||||
|
||||
function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addCrossingLines(image, context, Math.round(5 + ratio * 7), Math.round(60 + ratio * 60), ratio > 0.55 ? 2 : 1);
|
||||
addCrossingLines(
|
||||
image,
|
||||
context,
|
||||
Math.round(5 + ratio * 7),
|
||||
Math.round(60 + ratio * 60),
|
||||
ratio > 0.55 ? 2 : 1,
|
||||
);
|
||||
addDots(image, context, Math.round(120 + ratio * 320), 70);
|
||||
const bars = Math.round(2 + ratio * 5);
|
||||
for (let index = 0; index < bars; index++) {
|
||||
@@ -306,7 +327,15 @@ function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): voi
|
||||
|
||||
function applySnow(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addDots(image, context, Math.round(420 + ratio * 950), Math.round(85 + ratio * 75), 120, 245, ratio > 0.5 ? 2 : 1);
|
||||
addDots(
|
||||
image,
|
||||
context,
|
||||
Math.round(420 + ratio * 950),
|
||||
Math.round(85 + ratio * 75),
|
||||
120,
|
||||
245,
|
||||
ratio > 0.5 ? 2 : 1,
|
||||
);
|
||||
for (let index = 0; index < Math.round(35 + ratio * 90); index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
@@ -327,7 +356,14 @@ function applyCorrosion(
|
||||
const x = context.randomInt(layout.startX - 5, layout.startX + layout.textWidth + 5);
|
||||
const y = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
|
||||
const size = context.randomInt(1, ratio > 0.65 ? 4 : 3);
|
||||
fillRect(image, x, y, size, size, context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210]);
|
||||
fillRect(
|
||||
image,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
size,
|
||||
context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210],
|
||||
);
|
||||
}
|
||||
addDots(image, context, Math.round(170 + ratio * 430), 85, 90, 190, 2);
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 60, 1);
|
||||
@@ -341,12 +377,32 @@ function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): voi
|
||||
}));
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const current = nodes[index]!;
|
||||
const next = nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
|
||||
drawLine(image, current.x, current.y, next.x, next.y, [71, 85, 105, Math.round(65 + ratio * 65)], 1);
|
||||
if (index % 2 === 0) drawCircleOutline(image, current.x, current.y, context.randomInt(2, 5), [100, 116, 139, 70], 1);
|
||||
const next =
|
||||
nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
|
||||
drawLine(
|
||||
image,
|
||||
current.x,
|
||||
current.y,
|
||||
next.x,
|
||||
next.y,
|
||||
[71, 85, 105, Math.round(65 + ratio * 65)],
|
||||
1,
|
||||
);
|
||||
if (index % 2 === 0)
|
||||
drawCircleOutline(
|
||||
image,
|
||||
current.x,
|
||||
current.y,
|
||||
context.randomInt(2, 5),
|
||||
[100, 116, 139, 70],
|
||||
1,
|
||||
);
|
||||
}
|
||||
const anchorX = context.randomInt(Math.floor(image.width * 0.25), Math.floor(image.width * 0.75));
|
||||
const anchorY = context.randomInt(Math.floor(image.height * 0.25), Math.floor(image.height * 0.75));
|
||||
const anchorY = context.randomInt(
|
||||
Math.floor(image.height * 0.25),
|
||||
Math.floor(image.height * 0.75),
|
||||
);
|
||||
for (let index = 0; index < Math.round(7 + ratio * 7); index++) {
|
||||
const angle = (index / Math.round(7 + ratio * 7)) * Math.PI * 2;
|
||||
drawLine(
|
||||
@@ -365,8 +421,24 @@ function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): voi
|
||||
function applyCrossShadow(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const centerY = Math.floor(image.height / 2);
|
||||
drawLine(image, 0, centerY - 6, image.width - 1, centerY + 6, [37, 99, 235, 75], ratio > 0.55 ? 2 : 1);
|
||||
drawLine(image, 0, centerY + 7, image.width - 1, centerY - 8, [220, 38, 38, 65], ratio > 0.55 ? 2 : 1);
|
||||
drawLine(
|
||||
image,
|
||||
0,
|
||||
centerY - 6,
|
||||
image.width - 1,
|
||||
centerY + 6,
|
||||
[37, 99, 235, 75],
|
||||
ratio > 0.55 ? 2 : 1,
|
||||
);
|
||||
drawLine(
|
||||
image,
|
||||
0,
|
||||
centerY + 7,
|
||||
image.width - 1,
|
||||
centerY - 8,
|
||||
[220, 38, 38, 65],
|
||||
ratio > 0.55 ? 2 : 1,
|
||||
);
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 5), 55, 1);
|
||||
addDots(image, context, Math.round(120 + ratio * 300), 60);
|
||||
}
|
||||
@@ -399,11 +471,7 @@ function applySplit2(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
|
||||
}
|
||||
|
||||
function applyCut(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
function applyCut(image: RgbaImage, context: CaptchaGeneratorContext, layout: TextLayout): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const cuts = Math.round(4 + ratio * 8);
|
||||
for (let index = 0; index < cuts; index++) {
|
||||
@@ -459,12 +527,32 @@ function applyStitch(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const rows = Math.round(4 + ratio * 5);
|
||||
for (let index = 0; index < rows; index++) {
|
||||
const y = Math.round(((index + 1) / (rows + 1)) * image.height);
|
||||
drawDashedLine(image, 0, y, image.width - 1, y + context.randomInt(-3, 3), [71, 85, 105, 75], 4, 4, 1);
|
||||
drawDashedLine(
|
||||
image,
|
||||
0,
|
||||
y,
|
||||
image.width - 1,
|
||||
y + context.randomInt(-3, 3),
|
||||
[71, 85, 105, 75],
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
);
|
||||
}
|
||||
const columns = Math.round(2 + ratio * 4);
|
||||
for (let index = 0; index < columns; index++) {
|
||||
const x = context.randomInt(10, image.width - 10);
|
||||
drawDashedLine(image, x, 0, x + context.randomInt(-5, 5), image.height - 1, [100, 116, 139, 65], 3, 5, 1);
|
||||
drawDashedLine(
|
||||
image,
|
||||
x,
|
||||
0,
|
||||
x + context.randomInt(-5, 5),
|
||||
image.height - 1,
|
||||
[100, 116, 139, 65],
|
||||
3,
|
||||
5,
|
||||
1,
|
||||
);
|
||||
}
|
||||
for (let index = 0; index < Math.round(18 + ratio * 35); index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
@@ -479,7 +567,15 @@ function applyStriped(image: RgbaImage, context: CaptchaGeneratorContext): void
|
||||
const spacing = Math.round(10 - ratio * 4);
|
||||
const offset = context.randomInt(-image.height, image.width);
|
||||
for (let x = offset; x < image.width + image.height; x += spacing) {
|
||||
drawLine(image, x, 0, x - image.height, image.height - 1, [71, 85, 105, Math.round(45 + ratio * 45)], ratio > 0.7 ? 2 : 1);
|
||||
drawLine(
|
||||
image,
|
||||
x,
|
||||
0,
|
||||
x - image.height,
|
||||
image.height - 1,
|
||||
[71, 85, 105, Math.round(45 + ratio * 45)],
|
||||
ratio > 0.7 ? 2 : 1,
|
||||
);
|
||||
}
|
||||
for (let y = context.randomInt(3, 9); y < image.height; y += context.randomInt(8, 14)) {
|
||||
drawLine(image, 0, y, image.width - 1, y, [148, 163, 184, 45], 1);
|
||||
@@ -595,16 +691,20 @@ function applyBrokenLines(
|
||||
for (let index = 0; index < Math.round(12 + ratio * 28); index++) {
|
||||
const x = context.randomInt(0, image.width - 12);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
drawLine(image, x, y, x + context.randomInt(4, 18), y + context.randomInt(-2, 2), randomColor(context, 45, 175, 75), 1);
|
||||
drawLine(
|
||||
image,
|
||||
x,
|
||||
y,
|
||||
x + context.randomInt(4, 18),
|
||||
y + context.randomInt(-2, 2),
|
||||
randomColor(context, 45, 175, 75),
|
||||
1,
|
||||
);
|
||||
}
|
||||
addDots(image, context, Math.round(70 + ratio * 180), 60);
|
||||
}
|
||||
|
||||
function applyStyle(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
function applyStyle(image: RgbaImage, context: CaptchaGeneratorContext, layout: TextLayout): void {
|
||||
switch (context.imageStyle) {
|
||||
case "collision":
|
||||
applyCollision(image, context);
|
||||
@@ -667,10 +767,7 @@ function backgroundFor(style: CaptchaConcreteImageStyle): Rgba {
|
||||
return LIGHT_BACKGROUND;
|
||||
}
|
||||
|
||||
export function renderTextChallenge(
|
||||
text: string,
|
||||
context: CaptchaGeneratorContext,
|
||||
): string {
|
||||
export function renderTextChallenge(text: string, context: CaptchaGeneratorContext): string {
|
||||
const layout = layoutFor(text, context.imageStyle);
|
||||
const image = createImage(300, 104, backgroundFor(context.imageStyle));
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function defaultRandomBytes(length: number): Uint8Array {
|
||||
if (!Number.isInteger(length) || length < 1) throw new RangeError("random byte length must be positive");
|
||||
if (!Number.isInteger(length) || length < 1)
|
||||
throw new RangeError("random byte length must be positive");
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
|
||||
+160
-50
@@ -1,8 +1,19 @@
|
||||
import { defaultCaptchaGenerators } from "./challenges/index.ts";
|
||||
import { resolveCaptchaImageStyle } from "./challenges/styles.ts";
|
||||
import { AssetAudioRenderer } from "./audio/renderer.ts";
|
||||
import { bindingHash, constantTimeEqual, defaultRandomBytes, hmacSha256, randomId, sha256 } from "./crypto.ts";
|
||||
import { normalizeSelections, normalizeTextAnswer, normalizedSubmittedAnswer } from "./normalize.ts";
|
||||
import {
|
||||
bindingHash,
|
||||
constantTimeEqual,
|
||||
defaultRandomBytes,
|
||||
hmacSha256,
|
||||
randomId,
|
||||
sha256,
|
||||
} from "./crypto.ts";
|
||||
import {
|
||||
normalizeSelections,
|
||||
normalizeTextAnswer,
|
||||
normalizedSubmittedAnswer,
|
||||
} from "./normalize.ts";
|
||||
import { MemoryCaptchaStore } from "./stores/memory.ts";
|
||||
import type {
|
||||
CaptchaBinding,
|
||||
@@ -40,13 +51,16 @@ function failure(
|
||||
function assertAction(action: string): string {
|
||||
const value = action.trim();
|
||||
if (!value || value.length > 128 || !/^[a-z0-9][a-z0-9:._/-]*$/i.test(value)) {
|
||||
throw new TypeError("CAPTCHA action must be a non-empty stable identifier up to 128 characters");
|
||||
throw new TypeError(
|
||||
"CAPTCHA action must be a non-empty stable identifier up to 128 characters",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPositiveInteger(value: number, name: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) throw new RangeError(`${name} must be a positive integer`);
|
||||
if (!Number.isInteger(value) || value < 1)
|
||||
throw new RangeError(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -54,7 +68,8 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
|
||||
const fallback = difficulty === "easy" ? 25 : difficulty === "hard" ? 75 : 50;
|
||||
if (value === undefined) return fallback;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) throw new RangeError("disturbance must be a finite number between 25 and 75");
|
||||
if (!Number.isFinite(numeric))
|
||||
throw new RangeError("disturbance must be a finite number between 25 and 75");
|
||||
const normalized = Math.round(numeric);
|
||||
if (normalized < 25 || normalized > 75) {
|
||||
throw new RangeError("disturbance must be between 25 and 75");
|
||||
@@ -62,8 +77,13 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function randomInteger(randomBytes: (length: number) => Uint8Array, min: number, max: number): number {
|
||||
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min) throw new RangeError("invalid random range");
|
||||
function randomInteger(
|
||||
randomBytes: (length: number) => Uint8Array,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min)
|
||||
throw new RangeError("invalid random range");
|
||||
const span = max - min + 1;
|
||||
if (span === 1) return min;
|
||||
const limit = Math.floor(0x1_0000_0000 / span) * span;
|
||||
@@ -74,10 +94,13 @@ function randomInteger(randomBytes: (length: number) => Uint8Array, min: number,
|
||||
}
|
||||
}
|
||||
|
||||
async function matchesHash(expected: string | undefined, raw: string | undefined): Promise<boolean> {
|
||||
async function matchesHash(
|
||||
expected: string | undefined,
|
||||
raw: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!expected) return true;
|
||||
if (!raw) return false;
|
||||
return constantTimeEqual(expected, await bindingHash(raw) ?? "");
|
||||
return constantTimeEqual(expected, (await bindingHash(raw)) ?? "");
|
||||
}
|
||||
|
||||
export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
@@ -106,9 +129,18 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
this.store = options.store ?? new MemoryCaptchaStore();
|
||||
this.audioRenderer = options.audioRenderer ?? new AssetAudioRenderer();
|
||||
this.basePath = `/${(options.basePath ?? "/__wrnexus/captcha").replace(/^\/+|\/+$/g, "")}`;
|
||||
this.challengeTtlMs = assertPositiveInteger(options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS, "challengeTtlMs");
|
||||
this.responseTokenTtlMs = assertPositiveInteger(options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS, "responseTokenTtlMs");
|
||||
this.maxAttempts = assertPositiveInteger(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, "maxAttempts");
|
||||
this.challengeTtlMs = assertPositiveInteger(
|
||||
options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS,
|
||||
"challengeTtlMs",
|
||||
);
|
||||
this.responseTokenTtlMs = assertPositiveInteger(
|
||||
options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS,
|
||||
"responseTokenTtlMs",
|
||||
);
|
||||
this.maxAttempts = assertPositiveInteger(
|
||||
options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
"maxAttempts",
|
||||
);
|
||||
this.minCompletionMs = Math.max(0, options.minCompletionMs ?? DEFAULT_MIN_COMPLETION_MS);
|
||||
this.responseField = options.responseField ?? DEFAULT_RESPONSE_FIELD;
|
||||
this.defaultType = options.defaultType ?? "alphanumeric";
|
||||
@@ -116,19 +148,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
this.bindIp = options.bindIp ?? false;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.randomBytes = options.randomBytes ?? defaultRandomBytes;
|
||||
for (const generator of options.generators ?? defaultCaptchaGenerators()) this.generators.set(generator.type, generator);
|
||||
if (!this.generators.has(this.defaultType)) throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
|
||||
for (const generator of options.generators ?? defaultCaptchaGenerators())
|
||||
this.generators.set(generator.type, generator);
|
||||
if (!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 requestedPresentation = options.presentation ?? (
|
||||
requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
|
||||
const requestedPresentation =
|
||||
options.presentation ??
|
||||
(requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
|
||||
? "invisible"
|
||||
: "visual"
|
||||
);
|
||||
const actualType = requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
|
||||
: "visual");
|
||||
const actualType =
|
||||
requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
|
||||
const generator = this.generators.get(actualType);
|
||||
if (!generator) throw new Error(`No CAPTCHA generator registered for ${actualType}`);
|
||||
const now = this.now();
|
||||
@@ -161,19 +196,25 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
const id = randomId(this.randomBytes, 24);
|
||||
const answerSalt = randomId(this.randomBytes, 16);
|
||||
const caseSensitive = options.caseSensitive ?? false;
|
||||
const normalizedAnswer = generated.answerKind === "selections"
|
||||
? normalizeSelections(generated.answer.split(","))
|
||||
: generated.answerKind === "text"
|
||||
? normalizeTextAnswer(generated.answer, caseSensitive)
|
||||
: generated.answer;
|
||||
const normalizedAnswer =
|
||||
generated.answerKind === "selections"
|
||||
? normalizeSelections(generated.answer.split(","))
|
||||
: generated.answerKind === "text"
|
||||
? normalizeTextAnswer(generated.answer, caseSensitive)
|
||||
: generated.answer;
|
||||
const answerDigest = await hmacSha256(this.secret, `${id}:${answerSalt}:${normalizedAnswer}`);
|
||||
const expiresAt = now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
|
||||
const maxAttempts = assertPositiveInteger(options.maxAttempts ?? this.maxAttempts, "maxAttempts");
|
||||
const expiresAt =
|
||||
now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
|
||||
const maxAttempts = assertPositiveInteger(
|
||||
options.maxAttempts ?? this.maxAttempts,
|
||||
"maxAttempts",
|
||||
);
|
||||
const audioKey = generated.audioSequence?.length ? randomId(this.randomBytes, 18) : undefined;
|
||||
const responseField = options.responseField ?? this.responseField;
|
||||
const presentation = requestedPresentation === "audio" && generated.audioSequence?.length
|
||||
? "audio"
|
||||
: generated.presentation;
|
||||
const presentation =
|
||||
requestedPresentation === "audio" && generated.audioSequence?.length
|
||||
? "audio"
|
||||
: generated.presentation;
|
||||
|
||||
const publicChallenge: CaptchaChallenge = {
|
||||
id,
|
||||
@@ -190,7 +231,9 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
items: presentation === "audio" ? undefined : generated.items,
|
||||
minSelections: generated.minSelections,
|
||||
maxSelections: generated.maxSelections,
|
||||
audioUrl: audioKey ? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}` : undefined,
|
||||
audioUrl: audioKey
|
||||
? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}`
|
||||
: undefined,
|
||||
refreshUrl: `${this.basePath}/challenge`,
|
||||
verifyUrl: `${this.basePath}/verify`,
|
||||
honeypotField: String(generated.metadata?.honeypotField ?? "") || undefined,
|
||||
@@ -202,9 +245,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
requestedImageStyle: context.requestedImageStyle,
|
||||
imageStylePool: [...context.imageStylePool],
|
||||
locale: context.locale,
|
||||
...(generated.answerKind === "invisible" ? { minCompletionMs: context.minCompletionMs } : {}),
|
||||
...(generated.answerKind === "invisible"
|
||||
? { minCompletionMs: context.minCompletionMs }
|
||||
: {}),
|
||||
...(generated.metadata?.interaction ? { interaction: generated.metadata.interaction } : {}),
|
||||
...(requestedType === "image" && actualType !== requestedType ? { alternativeFor: requestedType } : {}),
|
||||
...(requestedType === "image" && actualType !== requestedType
|
||||
? { alternativeFor: requestedType }
|
||||
: {}),
|
||||
...options.metadata,
|
||||
},
|
||||
};
|
||||
@@ -248,14 +295,23 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
if (!input.challengeId) return failure(action, "missing-input", "Missing CAPTCHA challenge id");
|
||||
const now = this.now();
|
||||
const record = await this.store.getChallenge(input.challengeId);
|
||||
if (!record) return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
|
||||
if (record.expiresAt <= now) return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
|
||||
if (record.consumedAt) return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
|
||||
if (!record)
|
||||
return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
|
||||
if (record.expiresAt <= now)
|
||||
return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
|
||||
if (record.consumedAt)
|
||||
return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
|
||||
const bindingFailure = await this.checkChallengeBinding(record, input, action);
|
||||
if (bindingFailure) return bindingFailure;
|
||||
|
||||
const attempted = await this.store.incrementAttempts(record.id, now);
|
||||
if (!attempted) return failure(action, "already-used", "The CAPTCHA challenge is no longer available", record.id);
|
||||
if (!attempted)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA challenge is no longer available",
|
||||
record.id,
|
||||
);
|
||||
if (attempted.attempts > attempted.maxAttempts) {
|
||||
await this.store.consumeChallenge(attempted.id, now);
|
||||
return failure(action, "attempts-exhausted", "Too many CAPTCHA attempts", record.id);
|
||||
@@ -269,7 +325,10 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
}
|
||||
|
||||
const submitted = normalizedSubmittedAnswer(attempted, input);
|
||||
const digest = await hmacSha256(this.secret, `${attempted.id}:${attempted.answerSalt}:${submitted}`);
|
||||
const digest = await hmacSha256(
|
||||
this.secret,
|
||||
`${attempted.id}:${attempted.answerSalt}:${submitted}`,
|
||||
);
|
||||
if (!constantTimeEqual(attempted.answerDigest, digest)) {
|
||||
const exhausted = attempted.attempts >= attempted.maxAttempts;
|
||||
if (exhausted) await this.store.consumeChallenge(attempted.id, now);
|
||||
@@ -282,7 +341,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
}
|
||||
|
||||
const consumed = await this.store.consumeChallenge(attempted.id, now);
|
||||
if (!consumed) return failure(action, "already-used", "The CAPTCHA challenge was already used", attempted.id);
|
||||
if (!consumed)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA challenge was already used",
|
||||
attempted.id,
|
||||
);
|
||||
const plainToken = randomId(this.randomBytes, 32);
|
||||
const tokenHash = await sha256(plainToken);
|
||||
const expiresAt = now + this.responseTokenTtlMs;
|
||||
@@ -319,13 +384,33 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
const tokenHash = await sha256(token);
|
||||
const existing = await this.store.getToken(tokenHash);
|
||||
if (!existing) return failure(action, "invalid-input", "Unknown CAPTCHA response token");
|
||||
if (existing.expiresAt <= now) return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
|
||||
if (existing.consumedAt) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
|
||||
if (existing.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", existing.challengeId);
|
||||
if (existing.expiresAt <= now)
|
||||
return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
|
||||
if (existing.consumedAt)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA response token was already used",
|
||||
existing.challengeId,
|
||||
);
|
||||
if (existing.action !== action)
|
||||
return failure(
|
||||
action,
|
||||
"action-mismatch",
|
||||
"The CAPTCHA action does not match",
|
||||
existing.challengeId,
|
||||
);
|
||||
const bindingFailure = await this.checkTokenBinding(existing, input, action);
|
||||
if (bindingFailure) return bindingFailure;
|
||||
const record = input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
|
||||
if (!record) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
|
||||
const record =
|
||||
input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
|
||||
if (!record)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA response token was already used",
|
||||
existing.challengeId,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
provider: "self-hosted",
|
||||
@@ -338,13 +423,17 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
};
|
||||
}
|
||||
|
||||
async renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
|
||||
async renderAudio(
|
||||
challengeId: string,
|
||||
key: string,
|
||||
): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
|
||||
const record = await this.store.getChallenge(challengeId);
|
||||
if (!record || record.expiresAt <= this.now() || record.consumedAt) return undefined;
|
||||
const expectedKey = String(record.metadata.audioKey ?? "");
|
||||
if (!expectedKey || !constantTimeEqual(expectedKey, key)) return undefined;
|
||||
const sequence = record.metadata.audioSequence;
|
||||
if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string")) return undefined;
|
||||
if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string"))
|
||||
return undefined;
|
||||
const bytes = await this.audioRenderer.render(sequence, String(record.metadata.locale ?? "en"));
|
||||
return { bytes, contentType: this.audioRenderer.contentType ?? "audio/wav" };
|
||||
}
|
||||
@@ -358,7 +447,8 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
input: CaptchaBinding,
|
||||
action: string,
|
||||
): Promise<CaptchaVerificationResult | undefined> {
|
||||
if (record.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
|
||||
if (record.action !== action)
|
||||
return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
|
||||
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
|
||||
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.id);
|
||||
}
|
||||
@@ -366,7 +456,12 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.id);
|
||||
}
|
||||
if (!(await matchesHash(record.ipHash, input.ip))) {
|
||||
return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.id);
|
||||
return failure(
|
||||
action,
|
||||
"ip-mismatch",
|
||||
"The CAPTCHA network binding does not match",
|
||||
record.id,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -377,13 +472,28 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
action: string,
|
||||
): Promise<CaptchaVerificationResult | undefined> {
|
||||
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
|
||||
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.challengeId);
|
||||
return failure(
|
||||
action,
|
||||
"hostname-mismatch",
|
||||
"The CAPTCHA hostname does not match",
|
||||
record.challengeId,
|
||||
);
|
||||
}
|
||||
if (!(await matchesHash(record.sessionHash, input.sessionId))) {
|
||||
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.challengeId);
|
||||
return failure(
|
||||
action,
|
||||
"session-mismatch",
|
||||
"The CAPTCHA session does not match",
|
||||
record.challengeId,
|
||||
);
|
||||
}
|
||||
if (!(await matchesHash(record.ipHash, input.ip))) {
|
||||
return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.challengeId);
|
||||
return failure(
|
||||
action,
|
||||
"ip-mismatch",
|
||||
"The CAPTCHA network binding does not match",
|
||||
record.challengeId,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ function json(body: unknown, status = 200, headers: HeadersInit = {}): Response
|
||||
|
||||
async function readPayload(request: Request): Promise<Record<string, unknown>> {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) return (await request.json()) as Record<string, unknown>;
|
||||
if (contentType.includes("application/json"))
|
||||
return (await request.json()) as Record<string, unknown>;
|
||||
if (contentType.includes("form")) {
|
||||
const form = await request.formData();
|
||||
const payload: Record<string, unknown> = {};
|
||||
@@ -88,15 +89,22 @@ export function createCaptchaHttpHandlers(
|
||||
counters.set(key, counter);
|
||||
}
|
||||
counter.count += 1;
|
||||
return { allowed: counter.count <= maximum, retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)) };
|
||||
return {
|
||||
allowed: counter.count <= maximum,
|
||||
retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)),
|
||||
};
|
||||
};
|
||||
|
||||
const create = async (request: Request, ctx?: Context): Promise<Response> => {
|
||||
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
|
||||
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
if (request.method !== "POST")
|
||||
return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
const ip = clientIp(request, ctx, options.trustProxy ?? false);
|
||||
const limit = withinLimit(`create:${ip}`, options.createLimit ?? 30);
|
||||
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
|
||||
if (!limit.allowed)
|
||||
return json({ success: false, code: "rate-limited" }, 429, {
|
||||
"retry-after": String(limit.retryAfter),
|
||||
});
|
||||
try {
|
||||
const payload = await readPayload(request);
|
||||
const challenge = await engine.create({
|
||||
@@ -118,10 +126,14 @@ export function createCaptchaHttpHandlers(
|
||||
|
||||
const verify = async (request: Request, ctx?: Context): Promise<Response> => {
|
||||
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
|
||||
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
if (request.method !== "POST")
|
||||
return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
const ip = clientIp(request, ctx, options.trustProxy ?? false);
|
||||
const limit = withinLimit(`verify:${ip}`, options.verifyLimit ?? 60);
|
||||
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
|
||||
if (!limit.allowed)
|
||||
return json({ success: false, code: "rate-limited" }, 429, {
|
||||
"retry-after": String(limit.retryAfter),
|
||||
});
|
||||
try {
|
||||
const payload = await readPayload(request);
|
||||
const result = await engine.verify({
|
||||
@@ -143,7 +155,8 @@ export function createCaptchaHttpHandlers(
|
||||
|
||||
const audio = async (request: Request): Promise<Response> => {
|
||||
if (!originAllowed(request)) return new Response("Forbidden", { status: 403 });
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", { status: 405 });
|
||||
if (request.method !== "GET" && request.method !== "HEAD")
|
||||
return new Response("Method Not Allowed", { status: 405 });
|
||||
const url = new URL(request.url);
|
||||
const prefix = `${engine.basePath}/audio/`;
|
||||
const id = decodeURIComponent(url.pathname.slice(prefix.length));
|
||||
|
||||
@@ -76,7 +76,8 @@ export function captchaGuard(options: CaptchaGuardOptions) {
|
||||
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
|
||||
const result = await verifyRequest(ctx, options);
|
||||
ctx.locals.captcha = result;
|
||||
if (!result.success) return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
|
||||
if (!result.success)
|
||||
return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@@ -93,7 +94,7 @@ export function captchaPageGate(options: CaptchaPageGateOptions) {
|
||||
const grants = ctx.session.get<CaptchaSessionGrant[]>(sessionKey) ?? [];
|
||||
if (validCaptchaGrant(grants, action, now, routeGroup)) return next();
|
||||
|
||||
const signals = await options.signals?.(ctx) ?? {};
|
||||
const signals = (await options.signals?.(ctx)) ?? {};
|
||||
const decision = shouldRequireCaptcha(action, policy, signals);
|
||||
ctx.locals.captchaRisk = decision;
|
||||
if (!decision.challenge) return next();
|
||||
|
||||
@@ -13,7 +13,10 @@ export function normalizeSelections(values: unknown): string {
|
||||
return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort().join(",");
|
||||
}
|
||||
|
||||
export function normalizedSubmittedAnswer(record: CaptchaChallengeRecord, input: VerifyCaptchaInput): string {
|
||||
export function normalizedSubmittedAnswer(
|
||||
record: CaptchaChallengeRecord,
|
||||
input: VerifyCaptchaInput,
|
||||
): string {
|
||||
if (record.answerKind === "selections") return normalizeSelections(input.selections);
|
||||
if (record.answerKind === "invisible") {
|
||||
return JSON.stringify({
|
||||
|
||||
+101
-40
@@ -1,4 +1,3 @@
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin } from "@wrnexus/plugin";
|
||||
@@ -22,17 +21,6 @@ export interface CaptchaAuditIssue {
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const captchaClientRuntime = join(packageRoot, "assets", "client", "captcha.js");
|
||||
|
||||
async function copyCaptchaClientRuntime(destinationDir: string): Promise<void> {
|
||||
await mkdir(destinationDir, { recursive: true });
|
||||
await copyFile(captchaClientRuntime, join(destinationDir, "captcha.js"));
|
||||
}
|
||||
|
||||
async function installCaptchaClientRuntime(root: string, includeBuildOutput: boolean): Promise<void> {
|
||||
await copyCaptchaClientRuntime(join(root, "public", "assets", "wrnexus"));
|
||||
if (includeBuildOutput) {
|
||||
await copyCaptchaClientRuntime(join(root, "dist", "public", "assets", "wrnexus"));
|
||||
}
|
||||
}
|
||||
export function captchaComponentsDir(): string {
|
||||
return join(packageRoot, "components");
|
||||
}
|
||||
@@ -40,24 +28,54 @@ export function captchaComponentsDir(): string {
|
||||
function auditCaptchaSource(code: string, file: string, external: boolean): CaptchaAuditIssue[] {
|
||||
if (!code.includes("<Captcha") && !code.includes('data-component="Captcha"')) return [];
|
||||
const issues: CaptchaAuditIssue[] = [];
|
||||
const push = (id: string, severity: CaptchaAuditIssue["severity"], title: string, message: string) =>
|
||||
issues.push({ id: `${id}:${file}`, severity, title, message, file });
|
||||
const push = (
|
||||
id: string,
|
||||
severity: CaptchaAuditIssue["severity"],
|
||||
title: string,
|
||||
message: string,
|
||||
) => issues.push({ id: `${id}:${file}`, severity, title, message, file });
|
||||
|
||||
if (/secret(Key)?\s*=|providerSecret\s*=|captchaSecret\s*=/i.test(code)) {
|
||||
push("client-secret", "error", "CAPTCHA secret exposed", "Never pass a provider secret or verification secret to a .wrn component.");
|
||||
push(
|
||||
"client-secret",
|
||||
"error",
|
||||
"CAPTCHA secret exposed",
|
||||
"Never pass a provider secret or verification secret to a .wrn component.",
|
||||
);
|
||||
}
|
||||
if (!/action\s*=/.test(code)) {
|
||||
push("missing-action", "warning", "CAPTCHA action is missing", "Bind each challenge to a stable action such as signup, login, or contact-submit.");
|
||||
push(
|
||||
"missing-action",
|
||||
"warning",
|
||||
"CAPTCHA action is missing",
|
||||
"Bind each challenge to a stable action such as signup, login, or contact-submit.",
|
||||
);
|
||||
}
|
||||
if (/required\s*=\s*["']?false/i.test(code)) {
|
||||
push("optional-captcha", "warning", "CAPTCHA is optional", "Protected forms should require a CAPTCHA response and verify it on the server.");
|
||||
push(
|
||||
"optional-captcha",
|
||||
"warning",
|
||||
"CAPTCHA is optional",
|
||||
"Protected forms should require a CAPTCHA response and verify it on the server.",
|
||||
);
|
||||
}
|
||||
if (!/showAudio\s*=|presentation\s*=\s*["']audio/i.test(code)) {
|
||||
push("audio-alternative", "suggestion", "Confirm an accessible alternative", "Visual challenges should offer an audio or non-visual alternative.");
|
||||
push(
|
||||
"audio-alternative",
|
||||
"suggestion",
|
||||
"Confirm an accessible alternative",
|
||||
"Visual challenges should offer an audio or non-visual alternative.",
|
||||
);
|
||||
}
|
||||
|
||||
const imageStyle = code.match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1]?.trim().toLowerCase();
|
||||
if (imageStyle && !CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number])) {
|
||||
const imageStyle = code
|
||||
.match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
if (
|
||||
imageStyle &&
|
||||
!CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number])
|
||||
) {
|
||||
push(
|
||||
"unknown-image-style",
|
||||
"error",
|
||||
@@ -67,7 +85,11 @@ function auditCaptchaSource(code: string, file: string, external: boolean): Capt
|
||||
}
|
||||
|
||||
const disturbance = Number(code.match(/disturbance\s*=\s*["']?(\d+)/i)?.[1] ?? "");
|
||||
if (Number.isFinite(disturbance) && disturbance >= 65 && /showAudio\s*=\s*["']?false/i.test(code)) {
|
||||
if (
|
||||
Number.isFinite(disturbance) &&
|
||||
disturbance >= 65 &&
|
||||
/showAudio\s*=\s*["']?false/i.test(code)
|
||||
) {
|
||||
push(
|
||||
"hard-without-audio",
|
||||
"warning",
|
||||
@@ -75,10 +97,24 @@ function auditCaptchaSource(code: string, file: string, external: boolean): Capt
|
||||
"High disturbance should include audio or another non-visual challenge path.",
|
||||
);
|
||||
}
|
||||
if (external && /provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) && !/siteKey\s*=/.test(code)) {
|
||||
push("missing-site-key", "error", "Provider site key is missing", "External CAPTCHA providers require a public site key in the browser.");
|
||||
if (
|
||||
external &&
|
||||
/provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) &&
|
||||
!/siteKey\s*=/.test(code)
|
||||
) {
|
||||
push(
|
||||
"missing-site-key",
|
||||
"error",
|
||||
"Provider site key is missing",
|
||||
"External CAPTCHA providers require a public site key in the browser.",
|
||||
);
|
||||
}
|
||||
push("server-verification", "suggestion", "Server verification required", "Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().");
|
||||
push(
|
||||
"server-verification",
|
||||
"suggestion",
|
||||
"Server verification required",
|
||||
"Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().",
|
||||
);
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -86,37 +122,62 @@ export function captchaPlugin(options: CaptchaPluginOptions = {}) {
|
||||
const metadataKey = "@wrnexus/captcha:audit";
|
||||
return definePlugin({
|
||||
name: "@wrnexus/captcha",
|
||||
version: "0.3.6",
|
||||
version: "0.4.0",
|
||||
enforce: "post",
|
||||
async configure(config, context) {
|
||||
componentDirs:
|
||||
options.exposeComponentDirectory === false
|
||||
? []
|
||||
: [options.componentDir ?? captchaComponentsDir()],
|
||||
clientRuntimes: [
|
||||
{
|
||||
id: "captcha",
|
||||
entry: captchaClientRuntime,
|
||||
type: "script",
|
||||
load: "defer",
|
||||
singleton: true,
|
||||
bundle: false,
|
||||
},
|
||||
],
|
||||
styleSources: [
|
||||
{
|
||||
id: "captcha-components",
|
||||
source: options.componentDir ?? captchaComponentsDir(),
|
||||
order: "normal",
|
||||
},
|
||||
],
|
||||
configure(config, context) {
|
||||
const current = (config.captcha ?? {}) as Record<string, unknown>;
|
||||
config.captcha = {
|
||||
componentDir: options.componentDir ?? captchaComponentsDir(),
|
||||
...current,
|
||||
};
|
||||
context.metadata.set("@wrnexus/captcha:component-dir", options.componentDir ?? captchaComponentsDir());
|
||||
await installCaptchaClientRuntime(context.root, context.command === "build");
|
||||
context.metadata.set(
|
||||
"@wrnexus/captcha:component-dir",
|
||||
options.componentDir ?? captchaComponentsDir(),
|
||||
);
|
||||
},
|
||||
transformCode(code, context) {
|
||||
if (context.mode !== "development") return;
|
||||
const previous = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
|
||||
const withoutFile = previous.filter((issue) => issue.file !== context.file);
|
||||
context.metadata.set(
|
||||
metadataKey,
|
||||
[...withoutFile, ...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true)],
|
||||
);
|
||||
context.metadata.set(metadataKey, [
|
||||
...withoutFile,
|
||||
...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true),
|
||||
]);
|
||||
},
|
||||
devToolbarPanels(context) {
|
||||
if (options.enableDevToolbar === false) return [];
|
||||
const issues = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
|
||||
return [{
|
||||
id: "wrnexus-captcha",
|
||||
title: "CAPTCHA",
|
||||
icon: "shield-check",
|
||||
badge: issues.length,
|
||||
description: "CAPTCHA security, accessibility, and integration checks",
|
||||
issues,
|
||||
}];
|
||||
return [
|
||||
{
|
||||
id: "wrnexus-captcha",
|
||||
title: "CAPTCHA",
|
||||
icon: "shield-check",
|
||||
badge: issues.length,
|
||||
description: "CAPTCHA security, accessibility, and integration checks",
|
||||
issues,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
CaptchaPolicyOptions,
|
||||
CaptchaRiskResult,
|
||||
CaptchaRiskSignals,
|
||||
} from "./types.ts";
|
||||
import type { CaptchaPolicyOptions, CaptchaRiskResult, CaptchaRiskSignals } from "./types.ts";
|
||||
|
||||
export function evaluateCaptchaRisk(
|
||||
signals: CaptchaRiskSignals,
|
||||
@@ -14,8 +10,10 @@ export function evaluateCaptchaRisk(
|
||||
score = Math.min(100, score + points);
|
||||
reasons.push(reason);
|
||||
};
|
||||
if ((signals.failedAttempts ?? 0) > 0) add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
|
||||
if ((signals.requestsInWindow ?? 0) > 20) add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
|
||||
if ((signals.failedAttempts ?? 0) > 0)
|
||||
add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
|
||||
if ((signals.requestsInWindow ?? 0) > 20)
|
||||
add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
|
||||
if (signals.completionMs !== undefined && signals.completionMs < 700) add(25, "too-fast");
|
||||
if (signals.missingBrowserSignals) add(20, "missing-browser-signals");
|
||||
if (signals.suspiciousHeaders) add(20, "suspicious-headers");
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { CaptchaProvider } from "../types.ts";
|
||||
|
||||
export function defineCaptchaProvider<T extends CaptchaProvider>(provider: T): T {
|
||||
if (!provider.name) throw new TypeError("Custom CAPTCHA provider requires a stable name");
|
||||
if (!provider.client?.responseField) throw new TypeError("Custom CAPTCHA provider requires client.responseField");
|
||||
if (typeof provider.verify !== "function") throw new TypeError("Custom CAPTCHA provider requires verify()");
|
||||
if (!provider.client?.responseField)
|
||||
throw new TypeError("Custom CAPTCHA provider requires client.responseField");
|
||||
if (typeof provider.verify !== "function")
|
||||
throw new TypeError("Custom CAPTCHA provider requires verify()");
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -32,10 +32,14 @@ export class ManagedCaptchaProvider implements CaptchaProvider {
|
||||
}
|
||||
|
||||
async createChallenge(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
|
||||
return this.request<CaptchaChallenge>("/v1/challenges", {
|
||||
siteKey: this.options.siteKey,
|
||||
...options,
|
||||
}, false);
|
||||
return this.request<CaptchaChallenge>(
|
||||
"/v1/challenges",
|
||||
{
|
||||
siteKey: this.options.siteKey,
|
||||
...options,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
|
||||
@@ -64,6 +68,8 @@ export class ManagedCaptchaProvider implements CaptchaProvider {
|
||||
}
|
||||
}
|
||||
|
||||
export function managedCaptchaProvider(options: ManagedCaptchaProviderOptions): ManagedCaptchaProvider {
|
||||
export function managedCaptchaProvider(
|
||||
options: ManagedCaptchaProviderOptions,
|
||||
): ManagedCaptchaProvider {
|
||||
return new ManagedCaptchaProvider(options);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ export class SelfHostedCaptchaProvider implements CaptchaProvider {
|
||||
|
||||
verify(input: VerifyCaptchaInput) {
|
||||
return input.responseToken || input.providerToken
|
||||
? this.engine.verifyResponseToken({ ...input, responseToken: input.responseToken ?? input.providerToken })
|
||||
? this.engine.verifyResponseToken({
|
||||
...input,
|
||||
responseToken: input.responseToken ?? input.providerToken,
|
||||
})
|
||||
: this.engine.verify(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,10 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
|
||||
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
|
||||
const token = input.providerToken ?? input.responseToken;
|
||||
const action = input.action;
|
||||
if (!token) return providerFailure(this.name, action, "missing-input", "Missing provider response token");
|
||||
if (token.length > 4096) return providerFailure(this.name, action, "invalid-input", "Provider token is too long");
|
||||
if (!token)
|
||||
return providerFailure(this.name, action, "missing-input", "Missing provider response token");
|
||||
if (token.length > 4096)
|
||||
return providerFailure(this.name, action, "invalid-input", "Provider token is too long");
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
|
||||
const body = new URLSearchParams({ secret: this.options.secretKey, response: token });
|
||||
@@ -79,7 +81,12 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return providerFailure(this.name, action, "provider-error", `${this.name} verification returned HTTP ${response.status}`);
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
"provider-error",
|
||||
`${this.name} verification returned HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as SiteverifyPayload;
|
||||
if (!data.success) {
|
||||
@@ -98,26 +105,45 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
|
||||
}
|
||||
const expectedAction = this.options.expectedAction ?? action;
|
||||
if (data.action && expectedAction && data.action !== expectedAction) {
|
||||
return providerFailure(this.name, action, "action-mismatch", "Provider action does not match", {
|
||||
receivedAction: data.action,
|
||||
});
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
"action-mismatch",
|
||||
"Provider action does not match",
|
||||
{
|
||||
receivedAction: data.action,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.options.expectedHostnames?.length &&
|
||||
(!data.hostname || !this.options.expectedHostnames.includes(data.hostname))
|
||||
) {
|
||||
return providerFailure(this.name, action, "hostname-mismatch", "Provider hostname does not match", {
|
||||
hostname: data.hostname,
|
||||
});
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
"hostname-mismatch",
|
||||
"Provider hostname does not match",
|
||||
{
|
||||
hostname: data.hostname,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (this.options.minScore !== undefined && typeof data.score === "number") {
|
||||
const rejected = this.preset.scoreDirection === "higher-is-risk"
|
||||
? data.score >= this.options.minScore
|
||||
: data.score < this.options.minScore;
|
||||
const rejected =
|
||||
this.preset.scoreDirection === "higher-is-risk"
|
||||
? data.score >= this.options.minScore
|
||||
: data.score < this.options.minScore;
|
||||
if (rejected) {
|
||||
return providerFailure(this.name, action, "risk-rejected", "Provider risk score did not pass", {
|
||||
score: data.score,
|
||||
});
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
"risk-rejected",
|
||||
"Provider risk score did not pass",
|
||||
{
|
||||
score: data.score,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -135,7 +161,9 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
error instanceof DOMException && error.name === "AbortError" ? "network-error" : "provider-error",
|
||||
error instanceof DOMException && error.name === "AbortError"
|
||||
? "network-error"
|
||||
: "provider-error",
|
||||
error instanceof DOMException && error.name === "AbortError"
|
||||
? `${this.name} verification timed out`
|
||||
: `${this.name} verification failed`,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
|
||||
|
||||
function cloneChallenge(record: CaptchaChallengeRecord): CaptchaChallengeRecord {
|
||||
return structuredClone(record);
|
||||
@@ -72,7 +68,10 @@ export class MemoryCaptchaStore implements CaptchaStore {
|
||||
return record ? cloneToken(record) : undefined;
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
async consumeToken(
|
||||
tokenHash: string,
|
||||
now: number,
|
||||
): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const record = this.tokens.get(tokenHash);
|
||||
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
||||
record.consumedAt = now;
|
||||
@@ -96,7 +95,11 @@ export class MemoryCaptchaStore implements CaptchaStore {
|
||||
}
|
||||
}
|
||||
|
||||
private evict<T extends { expiresAt: number }>(map: Map<string, T>, max: number, now: number): void {
|
||||
private evict<T extends { expiresAt: number }>(
|
||||
map: Map<string, T>,
|
||||
max: number,
|
||||
now: number,
|
||||
): void {
|
||||
for (const [key, record] of map) if (record.expiresAt <= now) map.delete(key);
|
||||
while (map.size >= max) map.delete(map.keys().next().value!);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
|
||||
|
||||
export interface RedisCaptchaClient {
|
||||
get(key: string): Promise<string | null> | string | null;
|
||||
@@ -94,7 +90,10 @@ export class RedisCaptchaStore implements CaptchaStore {
|
||||
return this.read<CaptchaResponseTokenRecord>(this.tokenKey(tokenHash));
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
async consumeToken(
|
||||
tokenHash: string,
|
||||
now: number,
|
||||
): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const key = this.tokenKey(tokenHash);
|
||||
if (this.redis.eval) {
|
||||
const raw = await this.redis.eval(CONSUME_TOKEN, {
|
||||
@@ -107,7 +106,9 @@ export class RedisCaptchaStore implements CaptchaStore {
|
||||
const current = await this.read<CaptchaResponseTokenRecord>(key);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.consumedAt = now;
|
||||
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
|
||||
await this.redis.set(key, JSON.stringify(current), {
|
||||
px: Math.max(1, current.expiresAt - now),
|
||||
});
|
||||
return current;
|
||||
});
|
||||
}
|
||||
@@ -138,7 +139,9 @@ export class RedisCaptchaStore implements CaptchaStore {
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
if (operation === "attempt") current.attempts += 1;
|
||||
else current.consumedAt = now;
|
||||
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
|
||||
await this.redis.set(key, JSON.stringify(current), {
|
||||
px: Math.max(1, current.expiresAt - now),
|
||||
});
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
|
||||
|
||||
export interface SqliteStatementLike {
|
||||
run(...params: unknown[]): unknown;
|
||||
@@ -21,16 +17,21 @@ export interface SqliteCaptchaStoreOptions {
|
||||
}
|
||||
|
||||
function safeIdentifier(value: string): string {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new TypeError(`Unsafe SQL identifier: ${value}`);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
|
||||
throw new TypeError(`Unsafe SQL identifier: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseChallenge(row: Record<string, unknown> | undefined): CaptchaChallengeRecord | undefined {
|
||||
function parseChallenge(
|
||||
row: Record<string, unknown> | undefined,
|
||||
): CaptchaChallengeRecord | undefined {
|
||||
if (!row) return undefined;
|
||||
return JSON.parse(String(row.payload)) as CaptchaChallengeRecord;
|
||||
}
|
||||
|
||||
function parseToken(row: Record<string, unknown> | undefined): CaptchaResponseTokenRecord | undefined {
|
||||
function parseToken(
|
||||
row: Record<string, unknown> | undefined,
|
||||
): CaptchaResponseTokenRecord | undefined {
|
||||
if (!row) return undefined;
|
||||
return JSON.parse(String(row.payload)) as CaptchaResponseTokenRecord;
|
||||
}
|
||||
@@ -77,7 +78,13 @@ export class SqliteCaptchaStore implements CaptchaStore {
|
||||
(id, payload, expires_at, consumed_at, attempts)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(record.id, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null, record.attempts);
|
||||
.run(
|
||||
record.id,
|
||||
JSON.stringify(record),
|
||||
record.expiresAt,
|
||||
record.consumedAt ?? null,
|
||||
record.attempts,
|
||||
);
|
||||
}
|
||||
|
||||
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
|
||||
@@ -96,7 +103,9 @@ export class SqliteCaptchaStore implements CaptchaStore {
|
||||
SET payload = ?, attempts = ?
|
||||
WHERE id = ? AND expires_at > ? AND consumed_at IS NULL AND attempts = ?`,
|
||||
)
|
||||
.run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as { changes?: number };
|
||||
.run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as {
|
||||
changes?: number;
|
||||
};
|
||||
return result?.changes === 0 ? undefined : current;
|
||||
}
|
||||
|
||||
@@ -134,7 +143,10 @@ export class SqliteCaptchaStore implements CaptchaStore {
|
||||
);
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
async consumeToken(
|
||||
tokenHash: string,
|
||||
now: number,
|
||||
): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const current = await this.getToken(tokenHash);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.consumedAt = now;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
export type CaptchaProviderName =
|
||||
| "self-hosted"
|
||||
| "wrnexus-managed"
|
||||
| "turnstile"
|
||||
| "recaptcha"
|
||||
| "hcaptcha"
|
||||
| (string & {});
|
||||
"self-hosted" | "wrnexus-managed" | "turnstile" | "recaptcha" | "hcaptcha" | (string & {});
|
||||
|
||||
export type CaptchaChallengeType =
|
||||
| "number"
|
||||
@@ -231,7 +226,9 @@ export interface CaptchaGeneratorContext {
|
||||
|
||||
export interface CaptchaChallengeGenerator {
|
||||
readonly type: CaptchaChallengeType;
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge | Promise<GeneratedCaptchaChallenge>;
|
||||
generate(
|
||||
context: CaptchaGeneratorContext,
|
||||
): GeneratedCaptchaChallenge | Promise<GeneratedCaptchaChallenge>;
|
||||
}
|
||||
|
||||
export interface CaptchaAudioRenderer {
|
||||
@@ -263,7 +260,10 @@ export interface CaptchaEngine {
|
||||
create(options: CreateCaptchaOptions): Promise<CaptchaChallenge>;
|
||||
verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
|
||||
verifyResponseToken(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
|
||||
renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined>;
|
||||
renderAudio(
|
||||
challengeId: string,
|
||||
key: string,
|
||||
): Promise<{ bytes: Uint8Array; contentType: string } | undefined>;
|
||||
gc(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type { ObjectSchema, ParseResult } from "@wrnexus/validation";
|
||||
import type {
|
||||
CaptchaEngine,
|
||||
CaptchaProvider,
|
||||
CaptchaVerificationResult,
|
||||
} from "./types.ts";
|
||||
import type { CaptchaEngine, CaptchaProvider, CaptchaVerificationResult } from "./types.ts";
|
||||
import { selfHostedProvider } from "./providers/self-hosted.ts";
|
||||
|
||||
export interface ParseWithCaptchaOptions {
|
||||
@@ -28,7 +24,8 @@ export async function parseWithCaptcha<T = Record<string, unknown>>(
|
||||
options: ParseWithCaptchaOptions,
|
||||
): Promise<CaptchaParseResult<T>> {
|
||||
const parsed = schema.parse(input) as ParseResult<T>;
|
||||
const provider = options.provider ?? (options.engine ? selfHostedProvider(options.engine) : undefined);
|
||||
const provider =
|
||||
options.provider ?? (options.engine ? selfHostedProvider(options.engine) : undefined);
|
||||
if (!provider) throw new TypeError("parseWithCaptcha requires provider or engine");
|
||||
const field = options.responseField ?? provider.client.responseField;
|
||||
const token = input[field] ?? input.captchaToken ?? input.responseToken;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
+282
-25
@@ -21,7 +21,7 @@ import {
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { basename, extname, join, resolve } from "node:path";
|
||||
import { buildRouter, type Route } from "@wrnexus/router";
|
||||
import { getReactiveRuntime } from "@wrnexus/csr";
|
||||
import { assertValidAst, generate, parse } from "@wrnexus/compiler";
|
||||
@@ -40,7 +40,15 @@ import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@
|
||||
import { loadLocales, resolveI18n } from "@wrnexus/i18n";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { checkPerformanceBudgets } from "@wrnexus/core";
|
||||
import { createPluginRunner } from "@wrnexus/plugin";
|
||||
import { currentCliVersion } from "./update-notifier.ts";
|
||||
import {
|
||||
createPluginRunner,
|
||||
discoverPlugins,
|
||||
contentTypeForPath,
|
||||
type ClientRuntimeDefinition,
|
||||
type PackageAssetDefinition,
|
||||
type PluginContributions,
|
||||
} from "@wrnexus/plugin";
|
||||
|
||||
// Import the production server from the package specifier (not a source path) so
|
||||
// the generated entry resolves whether @wrnexus/dev-server is a workspace or an
|
||||
@@ -70,7 +78,12 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
}
|
||||
|
||||
const config = await loadAppConfig(root);
|
||||
const pluginRunner = createPluginRunner(config.plugins, {
|
||||
const discoveredPlugins = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
const pluginRunner = createPluginRunner(discoveredPlugins, {
|
||||
root,
|
||||
mode: "production",
|
||||
command: "build",
|
||||
@@ -80,6 +93,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
});
|
||||
await pluginRunner.configure(config as Record<string, unknown>);
|
||||
await pluginRunner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
const pluginContributions = await pluginRunner.contributions();
|
||||
const componentDirs = [uiComponentsDir(), ...pluginContributions.componentDirs];
|
||||
await pluginRunner.hook("buildStart");
|
||||
|
||||
// `.wrn` route files are compiled once into deterministic intermediate modules.
|
||||
@@ -120,26 +135,90 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
if (n >= 0) console.log(`✓ Queries: ${n} (db/${name}/queries.gen.ts)`);
|
||||
}
|
||||
|
||||
// Bundle DB migrations into the build so the production server can auto-apply
|
||||
// them on startup (dev auto-migrates from app/db/migrations; prod needs the
|
||||
// .sql files inside dist/). The default db's migrations go to dist/migrations;
|
||||
// each named db's to dist/db/<name>/migrations.
|
||||
// Bundle application and package-owned DB migrations into the build. Package
|
||||
// files are namespaced by contribution id so independently installed systems
|
||||
// cannot collide with application migration filenames.
|
||||
const safeMigrationName = (value: string): string =>
|
||||
value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_") || "migration";
|
||||
let hasDefaultMigrations = false;
|
||||
const namedMigrationDbs = new Set<string>();
|
||||
const migrationTarget = (database?: string): string => {
|
||||
const target = database?.trim() || "default";
|
||||
if (target === "default") {
|
||||
if (!config.db) {
|
||||
throw new Error(
|
||||
"WRN-PLUGIN-MIGRATION-DATABASE: a package targets the default database, but config.db is not configured.",
|
||||
);
|
||||
}
|
||||
hasDefaultMigrations = true;
|
||||
return join(distDir, "migrations");
|
||||
}
|
||||
if (!config.databases?.[target]) {
|
||||
throw new Error(
|
||||
`WRN-PLUGIN-MIGRATION-DATABASE: package migration targets unknown database '${target}'.`,
|
||||
);
|
||||
}
|
||||
namedMigrationDbs.add(target);
|
||||
return join(distDir, "db", target, "migrations");
|
||||
};
|
||||
|
||||
const defaultMigrationsSrc = join(appDir, "db", "migrations");
|
||||
const hasDefaultMigrations = !!config.db && existsSync(defaultMigrationsSrc);
|
||||
if (hasDefaultMigrations) {
|
||||
if (config.db && existsSync(defaultMigrationsSrc)) {
|
||||
cpSync(defaultMigrationsSrc, join(distDir, "migrations"), { recursive: true });
|
||||
console.log(`✓ Migrations: dist/migrations`);
|
||||
hasDefaultMigrations = true;
|
||||
console.log("✓ Migrations: dist/migrations");
|
||||
}
|
||||
const namedMigrationDbs: string[] = [];
|
||||
for (const name of Object.keys(config.databases ?? {})) {
|
||||
const src = join(appDir, "db", name, "migrations");
|
||||
if (!existsSync(src)) continue;
|
||||
cpSync(src, join(distDir, "db", name, "migrations"), { recursive: true });
|
||||
namedMigrationDbs.push(name);
|
||||
const source = join(appDir, "db", name, "migrations");
|
||||
if (!existsSync(source)) continue;
|
||||
cpSync(source, join(distDir, "db", name, "migrations"), { recursive: true });
|
||||
namedMigrationDbs.add(name);
|
||||
console.log(`✓ Migrations: dist/db/${name}/migrations`);
|
||||
}
|
||||
|
||||
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
|
||||
for (const migration of pluginContributions.migrations) {
|
||||
const destination = migrationTarget(migration.database);
|
||||
mkdirSync(destination, { recursive: true });
|
||||
const prefix = safeMigrationName(migration.id);
|
||||
if (migration.source !== undefined) {
|
||||
writeFileSync(join(destination, `${prefix}.sql`), migration.source, "utf8");
|
||||
continue;
|
||||
}
|
||||
if (!migration.entry || !existsSync(migration.entry)) {
|
||||
throw new Error(
|
||||
`WRN-PLUGIN-MIGRATION-MISSING: ${migration.id} points to ${migration.entry ?? "<empty>"}.`,
|
||||
);
|
||||
}
|
||||
const stat = statSync(migration.entry);
|
||||
if (stat.isDirectory()) {
|
||||
for (const file of readdirSync(migration.entry)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()) {
|
||||
cpSync(
|
||||
join(migration.entry, file),
|
||||
join(destination, `${prefix}__${safeMigrationName(file)}`),
|
||||
);
|
||||
}
|
||||
} else if (stat.isFile() && migration.entry.endsWith(".sql")) {
|
||||
cpSync(
|
||||
migration.entry,
|
||||
join(destination, `${prefix}__${safeMigrationName(basename(migration.entry))}`),
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`WRN-PLUGIN-MIGRATION-ENTRY: ${migration.id} must be a .sql file or directory.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (pluginContributions.migrations.length) {
|
||||
console.log(`✓ Package migrations: ${pluginContributions.migrations.length}`);
|
||||
}
|
||||
|
||||
const router = buildRouter(appDir, {
|
||||
componentDirs,
|
||||
externalRoutes: pluginContributions.routes,
|
||||
middlewareFiles: pluginContributions.middleware,
|
||||
});
|
||||
const wrnFiles = new Set([
|
||||
...router.pages.map((route) => route.file),
|
||||
...router.api.map((route) => route.file),
|
||||
@@ -149,6 +228,17 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
]);
|
||||
for (const file of wrnFiles) await compileWrn(file);
|
||||
const assetHash = createHash("sha256");
|
||||
const emittedPluginAssets = await emitPluginAssets(
|
||||
pluginContributions,
|
||||
distDir,
|
||||
config.build?.sourceMaps === true,
|
||||
);
|
||||
for (const asset of emittedPluginAssets.assets) assetHash.update(readFileSync(asset.file));
|
||||
if (emittedPluginAssets.assets.length) {
|
||||
console.log(
|
||||
`✓ Plugin assets: ${emittedPluginAssets.assets.length} (${emittedPluginAssets.runtimes.length} runtimes)`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Components are `.wrn` modules rendered server-side — no browser chunks.
|
||||
// They are compiled + statically imported into the manifest below.
|
||||
@@ -201,9 +291,20 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const styleEntry = findStyleEntry(appDir, root, config.styles?.entry);
|
||||
let hasStyles = false;
|
||||
let inlineStyles = "";
|
||||
if (styleEntry) {
|
||||
const hasPackageStyleEntries = pluginContributions.styles.some((style) => !!style.entry);
|
||||
if (styleEntry || hasPackageStyleEntries) {
|
||||
const css = await renderStyles(
|
||||
{ entryPath: styleEntry, appDir, appRoot: root, mode: "production" },
|
||||
{
|
||||
entryPath: styleEntry,
|
||||
appDir,
|
||||
appRoot: root,
|
||||
mode: "production",
|
||||
sources: [
|
||||
...componentDirs,
|
||||
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
||||
],
|
||||
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
||||
},
|
||||
config.styles,
|
||||
);
|
||||
assetHash.update(css);
|
||||
@@ -294,8 +395,8 @@ await createProductionServer(
|
||||
storage: ${config.storage ? JSON.stringify(config.storage) : "undefined"},
|
||||
${hasDefaultMigrations ? `migrationsDir: join(import.meta.dir, "migrations"),` : ""}
|
||||
${
|
||||
namedMigrationDbs.length
|
||||
? `databaseMigrationDirs: { ${namedMigrationDbs
|
||||
namedMigrationDbs.size
|
||||
? `databaseMigrationDirs: { ${[...namedMigrationDbs]
|
||||
.map(
|
||||
(n) =>
|
||||
`${JSON.stringify(n)}: join(import.meta.dir, "db", ${JSON.stringify(n)}, "migrations")`,
|
||||
@@ -309,6 +410,8 @@ await createProductionServer(
|
||||
${hasStyles ? `stylesIncludeFramework: true,` : ""}
|
||||
${inlineStyles ? `inlineStyles: ${JSON.stringify(inlineStyles)},` : ""}
|
||||
assetVersion: ${JSON.stringify(assetVersion)},
|
||||
clientRuntimes: ${JSON.stringify(emittedPluginAssets.runtimes)},
|
||||
pluginAssets: ${renderProductionPluginAssets(emittedPluginAssets.assets)},
|
||||
head: ${JSON.stringify(headStr)},
|
||||
seo: ${JSON.stringify(config.seo ?? {})},
|
||||
mobile: ${JSON.stringify(config.mobile ?? {})},
|
||||
@@ -342,10 +445,36 @@ await createProductionServer(
|
||||
distDir,
|
||||
publicDir: distPublicDir,
|
||||
adapter: config.build?.adapter ?? "bun",
|
||||
routes: router.pages,
|
||||
routes: [
|
||||
...router.pages.map((route) => ({ kind: "page" as const, route })),
|
||||
...router.api.map((route) => ({ kind: "api" as const, route })),
|
||||
...router.realtime.map((route) => ({ kind: "realtime" as const, route })),
|
||||
],
|
||||
runtimeFile: reactivePath,
|
||||
cssFile: hasStyles ? join(distDir, "styles.css") : join(distDir, "framework.css"),
|
||||
});
|
||||
report.pluginAssets = emittedPluginAssets.assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
publicPath: asset.publicPath,
|
||||
bytes: statSync(asset.file).size,
|
||||
runtime: asset.runtime,
|
||||
}));
|
||||
report.plugins = pluginRunner.plugins.map((plugin) => ({
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
}));
|
||||
report.clientRuntimes = emittedPluginAssets.runtimes.map((runtime) => ({
|
||||
id: runtime.id,
|
||||
publicPath: runtime.publicPath!,
|
||||
type: runtime.type ?? "module",
|
||||
load: runtime.load ?? "defer",
|
||||
}));
|
||||
report.migrations = pluginContributions.migrations.map((migration) => ({
|
||||
id: migration.id,
|
||||
database: migration.database ?? "default",
|
||||
source: migration.entry ?? "inline",
|
||||
}));
|
||||
report.componentDirs = componentDirs.map(fwd);
|
||||
const violations = checkPerformanceBudgets(
|
||||
config.performance?.budgets ?? {},
|
||||
report.measurements,
|
||||
@@ -374,12 +503,139 @@ await createProductionServer(
|
||||
console.log(`\nRun it: bun ${fwd(join(distDir, "server.js"))}`);
|
||||
}
|
||||
|
||||
interface EmittedPluginAsset {
|
||||
id: string;
|
||||
publicPath: string;
|
||||
file: string;
|
||||
contentType: string;
|
||||
runtime: boolean;
|
||||
immutable: boolean;
|
||||
}
|
||||
|
||||
interface EmittedPluginAssets {
|
||||
assets: EmittedPluginAsset[];
|
||||
runtimes: ClientRuntimeDefinition[];
|
||||
}
|
||||
|
||||
function safeAssetName(id: string): string {
|
||||
return (
|
||||
id
|
||||
.replace(/^@/, "")
|
||||
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "asset"
|
||||
);
|
||||
}
|
||||
|
||||
function sourceBytes(source: string | Uint8Array): Uint8Array {
|
||||
return typeof source === "string" ? new TextEncoder().encode(source) : source;
|
||||
}
|
||||
|
||||
async function compileRuntimeSource(
|
||||
runtime: ClientRuntimeDefinition,
|
||||
sourceMaps: boolean,
|
||||
): Promise<Uint8Array> {
|
||||
if (runtime.source !== undefined) return sourceBytes(runtime.source);
|
||||
if (!runtime.entry) throw new Error(`Runtime '${runtime.id}' has no entry.`);
|
||||
const shouldBundle = runtime.bundle ?? /\.[cm]?tsx?$/.test(runtime.entry);
|
||||
if (!shouldBundle) return readFileSync(runtime.entry);
|
||||
const result = await Bun.build({
|
||||
entrypoints: [runtime.entry],
|
||||
target: "browser",
|
||||
format: runtime.type === "script" ? "iife" : "esm",
|
||||
minify: true,
|
||||
sourcemap: sourceMaps ? "inline" : "none",
|
||||
});
|
||||
if (!result.success || !result.outputs[0]) {
|
||||
throw new Error(
|
||||
`Client runtime '${runtime.id}' failed to build:\n${result.logs.map(String).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return new Uint8Array(await result.outputs[0].arrayBuffer());
|
||||
}
|
||||
|
||||
async function rawAssetSource(asset: PackageAssetDefinition): Promise<Uint8Array> {
|
||||
if (asset.source !== undefined) return sourceBytes(asset.source);
|
||||
if (!asset.entry) throw new Error(`Asset '${asset.id}' has no entry.`);
|
||||
return readFileSync(asset.entry);
|
||||
}
|
||||
|
||||
async function emitPluginAssets(
|
||||
contributions: PluginContributions,
|
||||
distDir: string,
|
||||
sourceMaps: boolean,
|
||||
): Promise<EmittedPluginAssets> {
|
||||
const outputDir = join(distDir, "plugin-assets");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const assets: EmittedPluginAsset[] = [];
|
||||
const runtimes: ClientRuntimeDefinition[] = [];
|
||||
|
||||
for (const runtime of contributions.clientRuntimes) {
|
||||
const bytes = await compileRuntimeSource(runtime, sourceMaps);
|
||||
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
|
||||
const filename = `${safeAssetName(runtime.id)}.${hash}.js`;
|
||||
const file = join(outputDir, filename);
|
||||
writeFileSync(file, bytes);
|
||||
const publicPath = `/__wrnexus/assets/${filename}`;
|
||||
assets.push({
|
||||
id: runtime.id,
|
||||
publicPath,
|
||||
file,
|
||||
contentType: "text/javascript; charset=utf-8",
|
||||
runtime: true,
|
||||
immutable: true,
|
||||
});
|
||||
runtimes.push({ ...runtime, entry: undefined, source: undefined, publicPath });
|
||||
}
|
||||
|
||||
for (const asset of contributions.assets) {
|
||||
const bytes = await rawAssetSource(asset);
|
||||
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
|
||||
const extension = extname(asset.entry ?? asset.publicPath ?? "") || "";
|
||||
const filename = `${safeAssetName(asset.id)}.${hash}${extension}`;
|
||||
const file = join(outputDir, filename);
|
||||
writeFileSync(file, bytes);
|
||||
// Package assets keep their declared public URL so component markup, CSS,
|
||||
// and server responses do not need build-time string rewriting. The disk
|
||||
// filename is still content-addressed to make deployments atomic.
|
||||
assets.push({
|
||||
id: asset.id,
|
||||
publicPath: asset.publicPath!,
|
||||
file,
|
||||
contentType: asset.contentType ?? contentTypeForPath(asset.entry ?? asset.publicPath ?? ""),
|
||||
runtime: false,
|
||||
immutable: asset.immutable ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
return { assets, runtimes };
|
||||
}
|
||||
|
||||
function renderProductionPluginAssets(assets: readonly EmittedPluginAsset[]): string {
|
||||
if (!assets.length) return "{}";
|
||||
const entries = assets.map(
|
||||
(asset) =>
|
||||
`${JSON.stringify(asset.publicPath)}: { path: join(import.meta.dir, "plugin-assets", ${JSON.stringify(asset.file.split(/[\\/]/).pop())}), contentType: ${JSON.stringify(asset.contentType)}, immutable: ${asset.immutable} }`,
|
||||
);
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
interface BuildReport {
|
||||
frameworkVersion: string;
|
||||
plugins?: Array<{ name: string; version?: string }>;
|
||||
pluginAssets?: Array<{ id: string; publicPath: string; bytes: number; runtime: boolean }>;
|
||||
clientRuntimes?: Array<{ id: string; publicPath: string; type: string; load: string }>;
|
||||
migrations?: Array<{ id: string; database: string; source: string }>;
|
||||
componentDirs?: string[];
|
||||
generatedAt: string;
|
||||
root: string;
|
||||
adapter: string;
|
||||
routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>;
|
||||
routes: Array<{
|
||||
kind: "page" | "api" | "realtime";
|
||||
path: string;
|
||||
source: string;
|
||||
sourceBytes: number;
|
||||
dynamicParams: string[];
|
||||
}>;
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: { routeJsBytes: number; routeCssBytes: number; imageBytes: number };
|
||||
budgetViolations: ReturnType<typeof checkPerformanceBudgets>;
|
||||
@@ -406,7 +662,7 @@ function createBuildReport(input: {
|
||||
distDir: string;
|
||||
publicDir: string;
|
||||
adapter: string;
|
||||
routes: Route[];
|
||||
routes: Array<{ kind: "page" | "api" | "realtime"; route: Route }>;
|
||||
runtimeFile: string;
|
||||
cssFile: string;
|
||||
}): BuildReport {
|
||||
@@ -422,11 +678,12 @@ function createBuildReport(input: {
|
||||
.map(fileBytes),
|
||||
);
|
||||
return {
|
||||
frameworkVersion: "0.3.0",
|
||||
frameworkVersion: currentCliVersion(),
|
||||
generatedAt: new Date().toISOString(),
|
||||
root: input.root,
|
||||
adapter: input.adapter,
|
||||
routes: input.routes.map((route) => ({
|
||||
routes: input.routes.map(({ kind, route }) => ({
|
||||
kind,
|
||||
path: route.raw,
|
||||
source: fwd(route.file.replace(input.root, "").replace(/^\//, "")),
|
||||
sourceBytes: fileBytes(route.file),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { extname, join, resolve } from "node:path";
|
||||
import { diagnose } from "@wrnexus/syntax";
|
||||
import { buildRouter, findRouteConflicts } from "@wrnexus/router";
|
||||
import { loadAppConfig, validateAppConfig } from "@wrnexus/styles";
|
||||
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
|
||||
|
||||
export interface DoctorCheck {
|
||||
name: string;
|
||||
@@ -85,8 +86,13 @@ export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
const marker = (pkg.wrnexus as { version?: string } | undefined)?.version;
|
||||
checks.push({
|
||||
name: "update marker",
|
||||
ok: !marker || versionAtLeast(marker, "0.3.0"),
|
||||
detail: marker ? `project last migrated to ${marker}` : "missing; run `wrnexus update`",
|
||||
ok: !marker || versionAtLeast(marker, "0.4.0"),
|
||||
detail:
|
||||
marker && versionAtLeast(marker, "0.4.0")
|
||||
? `project last migrated to ${marker}`
|
||||
: marker
|
||||
? `project is on ${marker}; run \`wrnexus update 0.4.0\``
|
||||
: "missing; run `wrnexus update`",
|
||||
level: "warning",
|
||||
});
|
||||
} catch {
|
||||
@@ -182,6 +188,66 @@ export async function runDoctor(appRoot: string): Promise<boolean> {
|
||||
: "valid",
|
||||
level: issues.some((issue) => issue.severity === "error") ? "error" : "warning",
|
||||
});
|
||||
|
||||
const discovered = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
warn: (message) =>
|
||||
checks.push({ name: "package plugin discovery", ok: false, detail: message }),
|
||||
});
|
||||
const runner = createPluginRunner(discovered, {
|
||||
root,
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata: new Map(),
|
||||
warn: () => {},
|
||||
});
|
||||
await runner.configure(config as Record<string, unknown>);
|
||||
await runner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
const contributions = await runner.contributions();
|
||||
checks.push({
|
||||
name: "package plugins",
|
||||
ok: true,
|
||||
detail: `${runner.plugins.length} plugins, ${contributions.clientRuntimes.length} runtimes, ${contributions.assets.length} assets, ${contributions.routes.length} routes, ${contributions.migrations.length} migrations`,
|
||||
});
|
||||
|
||||
const missingContributions = [
|
||||
...contributions.componentDirs.map((path) => ({ kind: "component directory", path })),
|
||||
...contributions.clientRuntimes
|
||||
.filter((runtime) => runtime.entry)
|
||||
.map((runtime) => ({ kind: `runtime ${runtime.id}`, path: runtime.entry! })),
|
||||
...contributions.assets
|
||||
.filter((asset) => asset.entry)
|
||||
.map((asset) => ({ kind: `asset ${asset.id}`, path: asset.entry! })),
|
||||
...contributions.routes.map((route) => ({
|
||||
kind: `${route.kind} route ${route.path}`,
|
||||
path: route.entry,
|
||||
})),
|
||||
...contributions.middleware.map((path) => ({ kind: "middleware", path })),
|
||||
...contributions.migrations
|
||||
.filter((migration) => migration.entry)
|
||||
.map((migration) => ({ kind: `migration ${migration.id}`, path: migration.entry! })),
|
||||
].filter((entry) => !existsSync(entry.path));
|
||||
checks.push({
|
||||
name: "package contribution files",
|
||||
ok: missingContributions.length === 0,
|
||||
detail: missingContributions.length
|
||||
? missingContributions.map((entry) => `${entry.kind}: ${entry.path}`).join("; ")
|
||||
: "all discovered contribution files exist",
|
||||
});
|
||||
|
||||
const legacyCaptchaAssets = [
|
||||
"public/assets/wrnexus/captcha.js",
|
||||
"public/__wrnexus/captcha.js",
|
||||
].filter((path) => existsSync(join(root, path)));
|
||||
checks.push({
|
||||
name: "legacy CAPTCHA runtime copies",
|
||||
ok: legacyCaptchaAssets.length === 0,
|
||||
detail: legacyCaptchaAssets.length
|
||||
? `remove with wrnexus update 0.4.0: ${legacyCaptchaAssets.join(", ")}`
|
||||
: "none; CAPTCHA runtime is package-managed",
|
||||
level: "warning",
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
name: "resolved configuration",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { uiComponentsDir, uiComponentNames } from "@wrnexus/ui";
|
||||
import { uiComponentNames, uiComponentPath } from "@wrnexus/ui";
|
||||
|
||||
export function runEject(appRoot: string, names: string[]): void {
|
||||
const root = resolve(appRoot);
|
||||
@@ -20,12 +20,17 @@ export function runEject(appRoot: string, names: string[]): void {
|
||||
}
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const name of names) {
|
||||
if (!available.includes(name)) {
|
||||
console.error(`✗ Unknown component "${name}". Available: ${available.join(", ")}`);
|
||||
for (const requestedName of names) {
|
||||
const name = available.find(
|
||||
(componentName) => componentName.toLowerCase() === requestedName.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!name) {
|
||||
console.error(`✗ Unknown component "${requestedName}". Available: ${available.join(", ")}`);
|
||||
continue;
|
||||
}
|
||||
const src = join(uiComponentsDir(), `${name}.wrn`);
|
||||
|
||||
const src = uiComponentPath(name);
|
||||
const out = join(dest, `${name}.wrn`);
|
||||
if (existsSync(out)) {
|
||||
console.error(`✗ ${name}: app/components/${name}.wrn already exists — skipped`);
|
||||
|
||||
@@ -63,6 +63,8 @@ Usage:
|
||||
wrnexus doctor [app-dir] Check project structure, versions, syntax, routes, and config
|
||||
wrnexus config [app-dir] --explain Print the fully resolved profile configuration
|
||||
wrnexus analyze [app-dir] Inspect dist/build-report.json and performance budgets
|
||||
wrnexus inspect <target> [app-dir] Inspect packages | plugins | routes | assets | runtimes | styles | migrations | bundle
|
||||
wrnexus generate system <name> Scaffold a complete framework-native package
|
||||
|
||||
Profiles: pass --profile=<name> to dev/build/db (or set WRNEXUS_PROFILE) to load
|
||||
that profile's .env cascade and config overrides. e.g. wrnexus dev --profile=uat
|
||||
@@ -136,6 +138,12 @@ async function main(): Promise<void> {
|
||||
generateDocker(process.cwd());
|
||||
break;
|
||||
}
|
||||
if (rest[0] === "system") {
|
||||
const { generateSystem } = await import("./system.ts");
|
||||
const files = generateSystem(process.cwd(), rest[1] ?? "");
|
||||
console.log(`✓ Created @wrnexus/${rest[1]} (${files.length} files)`);
|
||||
break;
|
||||
}
|
||||
if (rest[0] === "mobile") {
|
||||
const { generateMobile, mobileOptions } = await import("./mobile.ts");
|
||||
await generateMobile(process.cwd(), mobileOptions(rest.slice(1)));
|
||||
@@ -192,6 +200,13 @@ async function main(): Promise<void> {
|
||||
await runConfigCommand(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
|
||||
break;
|
||||
}
|
||||
case "inspect": {
|
||||
const target = rest.find((arg) => !arg.startsWith("--"));
|
||||
const appRoot = rest.filter((arg) => !arg.startsWith("--"))[1] ?? ".";
|
||||
const { runInspect } = await import("./inspect.ts");
|
||||
await runInspect(appRoot, target, rest);
|
||||
break;
|
||||
}
|
||||
case "analyze": {
|
||||
const { runAnalyze } = await import("./analyze.ts");
|
||||
const healthy = runAnalyze(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
||||
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
import { uiComponentsDir } from "@wrnexus/ui";
|
||||
|
||||
export type InspectTarget =
|
||||
"packages" | "plugins" | "routes" | "assets" | "runtimes" | "styles" | "migrations" | "bundle";
|
||||
|
||||
function json(path: string): Record<string, any> | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Record<string, any>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function workspaceRoot(start: string): string {
|
||||
let current = resolve(start);
|
||||
while (true) {
|
||||
if (json(join(current, "package.json"))?.workspaces) return current;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return resolve(start);
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
function packageRows(root: string) {
|
||||
const dirs = [join(root, "packages"), join(root, "services")];
|
||||
const rows: Array<Record<string, unknown>> = [];
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (!statSync(path).isDirectory()) continue;
|
||||
const pkg = json(join(path, "package.json"));
|
||||
if (!pkg?.name) continue;
|
||||
rows.push({
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
private: pkg.private === true,
|
||||
path: relative(root, path).replace(/\\/g, "/"),
|
||||
plugin: !!pkg.wrnexus?.plugin,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
||||
}
|
||||
export async function inspectProject(appRoot: string, target: InspectTarget): Promise<unknown> {
|
||||
const root = resolve(appRoot);
|
||||
const workspace = workspaceRoot(root);
|
||||
if (target === "packages") return packageRows(workspace);
|
||||
if (target === "bundle") {
|
||||
const report = join(root, "dist", "build-report.json");
|
||||
if (!existsSync(report)) throw new Error("Run `wrnexus build` before inspecting the bundle.");
|
||||
return json(report);
|
||||
}
|
||||
const config = await loadAppConfig(root);
|
||||
const input = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
});
|
||||
const runner = createPluginRunner(input, {
|
||||
root,
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata: new Map(),
|
||||
warn: () => {},
|
||||
});
|
||||
await runner.configure(config as Record<string, unknown>);
|
||||
await runner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
const contributions = await runner.contributions();
|
||||
if (target === "plugins")
|
||||
return runner.plugins.map((plugin) => ({
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
enforce: plugin.enforce ?? "normal",
|
||||
}));
|
||||
if (target === "assets")
|
||||
return contributions.assets.map(({ id, publicPath, contentType, immutable }) => ({
|
||||
id,
|
||||
publicPath,
|
||||
contentType,
|
||||
immutable: immutable ?? false,
|
||||
}));
|
||||
if (target === "runtimes")
|
||||
return contributions.clientRuntimes.map(
|
||||
({ id, publicPath, type, load, singleton, bundle }) => ({
|
||||
id,
|
||||
publicPath,
|
||||
type,
|
||||
load,
|
||||
singleton,
|
||||
bundle,
|
||||
}),
|
||||
);
|
||||
if (target === "styles")
|
||||
return contributions.styles.map(({ id, entry, source, order }) => ({
|
||||
id,
|
||||
entry,
|
||||
source,
|
||||
order: order ?? "normal",
|
||||
}));
|
||||
if (target === "migrations")
|
||||
return contributions.migrations.map(({ id, entry, source, database }) => ({
|
||||
id,
|
||||
entry,
|
||||
inline: source !== undefined,
|
||||
database: database ?? "default",
|
||||
}));
|
||||
const router = buildRouter(join(root, "app"), {
|
||||
componentDirs: [uiComponentsDir(), ...contributions.componentDirs],
|
||||
externalRoutes: contributions.routes,
|
||||
middlewareFiles: contributions.middleware,
|
||||
});
|
||||
return {
|
||||
pages: createRouteManifest(nameRoutes(router.pages)),
|
||||
api: createRouteManifest(nameRoutes(router.api)),
|
||||
realtime: createRouteManifest(nameRoutes(router.realtime)),
|
||||
middleware: router.middlewareFiles.map((file) => relative(root, file).replace(/\\/g, "/")),
|
||||
components: router.components.map((component) => ({
|
||||
...component,
|
||||
file: relative(root, component.file).replace(/\\/g, "/"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runInspect(
|
||||
appRoot: string,
|
||||
targetArg?: string,
|
||||
args: string[] = [],
|
||||
): Promise<void> {
|
||||
const target = (targetArg ?? "plugins") as InspectTarget;
|
||||
if (
|
||||
![
|
||||
"packages",
|
||||
"plugins",
|
||||
"routes",
|
||||
"assets",
|
||||
"runtimes",
|
||||
"styles",
|
||||
"migrations",
|
||||
"bundle",
|
||||
].includes(target)
|
||||
)
|
||||
throw new Error(`Unknown inspect target: ${target}`);
|
||||
const value = await inspectProject(appRoot, target);
|
||||
if (args.includes("--json")) {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`WRNexus ${target}\n`);
|
||||
if (Array.isArray(value))
|
||||
for (const row of value)
|
||||
console.log(
|
||||
` ${Object.entries(row as Record<string, unknown>)
|
||||
.map(([key, item]) => `${key}=${String(item)}`)
|
||||
.join(" ")}`,
|
||||
);
|
||||
else console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
function pascal(value: string): string {
|
||||
return value
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0]!.toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function camel(value: string): string {
|
||||
const name = pascal(value);
|
||||
return name ? name[0]!.toLowerCase() + name.slice(1) : name;
|
||||
}
|
||||
|
||||
function safe(value: string): string {
|
||||
const name = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^@wrnexus\//, "")
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (!name) throw new Error("System name is required");
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Scaffold the standard package/component/runtime/test layout for a WRNexus system. */
|
||||
export function generateSystem(rootDir: string, input: string): string[] {
|
||||
const root = resolve(rootDir);
|
||||
const name = safe(input);
|
||||
const directory = join(root, "packages", name);
|
||||
if (existsSync(directory)) throw new Error(`Package already exists: packages/${name}`);
|
||||
|
||||
const className = pascal(name);
|
||||
const functionName = camel(name);
|
||||
const files: Record<string, string> = {
|
||||
"package.json":
|
||||
JSON.stringify(
|
||||
{
|
||||
name: `@wrnexus/${name}`,
|
||||
version: "0.4.0",
|
||||
type: "module",
|
||||
main: "./src/index.ts",
|
||||
exports: { ".": "./src/index.ts", "./plugin": "./src/plugin.ts" },
|
||||
files: ["src", "components", "assets", "README.md"],
|
||||
scripts: {
|
||||
test: "bun test",
|
||||
typecheck: "tsc --noEmit",
|
||||
check: "bun run typecheck && bun run test",
|
||||
},
|
||||
dependencies: {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
},
|
||||
devDependencies: { "@types/bun": "latest", typescript: "^5.9.2" },
|
||||
wrnexus: {
|
||||
plugin: { plugin: "./src/plugin.ts", export: "default", factory: true },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"src/index.ts": `export interface ${className}Options {\n enabled?: boolean;\n}\n\nexport function create${className}(options: ${className}Options = {}) {\n return { enabled: options.enabled !== false };\n}\n`,
|
||||
"src/plugin.ts": `import { dirname, join } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { definePlugin } from "@wrnexus/plugin";\n\nconst root = dirname(dirname(fileURLToPath(import.meta.url)));\n\nexport function ${functionName}Plugin() {\n return definePlugin({\n name: "@wrnexus/${name}",\n version: "0.4.0",\n componentDirs: [join(root, "components")],\n clientRuntimes: [\n {\n id: "${name}",\n entry: join(root, "assets", "client", "runtime.js"),\n type: "script",\n load: "defer",\n singleton: true,\n bundle: false,\n },\n ],\n styleSources: [{ id: "${name}-components", source: join(root, "components") }],\n });\n}\n\nexport default ${functionName}Plugin;\n`,
|
||||
[`components/${className}.wrn`]: `component ${className} {\n props {\n class = ""\n color = "primary"\n size = "normal"\n }\n\n view {\n <div\n {...attrs}\n data-wrnexus-runtime="${name}"\n class='{class}'\n >\n ${className}\n </div>\n }\n}\n`,
|
||||
"assets/client/runtime.js": `(function () {\n var runtimeId = ${JSON.stringify(name)};\n\n function mount(root) {\n (root || document)\n .querySelectorAll('[data-wrnexus-runtime="${name}"]')\n .forEach(function (node) {\n if (node.dataset.wrnexusMounted === runtimeId) return;\n node.dataset.wrnexusMounted = runtimeId;\n });\n }\n\n function unmount(root) {\n (root || document)\n .querySelectorAll('[data-wrnexus-runtime="${name}"]')\n .forEach(function (node) {\n if (node.dataset.wrnexusMounted === runtimeId) delete node.dataset.wrnexusMounted;\n });\n }\n\n window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};\n window.__wrnexusRuntimes[runtimeId] = { mount: mount, unmount: unmount };\n\n if (document.readyState === "loading") {\n document.addEventListener("DOMContentLoaded", function () { mount(document); }, { once: true });\n } else {\n mount(document);\n }\n})();\n`,
|
||||
"test/system.test.ts": `import { expect, test } from "bun:test";\nimport { create${className} } from "../src/index.ts";\n\ntest("${name} system initializes", () => {\n expect(create${className}().enabled).toBe(true);\n});\n`,
|
||||
"README.md": `# @wrnexus/${name}\n\nFramework-native WRNexusJS system package generated by \`wrnexus generate system ${name}\`.\n\nThe component directory and browser runtime are discovered automatically when the package is present in an application's dependencies. No public asset copy or manual script tag is required.\n`,
|
||||
};
|
||||
|
||||
const written: string[] = [];
|
||||
for (const [file, content] of Object.entries(files)) {
|
||||
const target = join(directory, file);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, content, "utf8");
|
||||
written.push(target);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -971,7 +972,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.3.3",
|
||||
id: "component-fix",
|
||||
id: "component-fix-0-3-3",
|
||||
description: "UI component fixes.",
|
||||
apply() {
|
||||
// Compiler-only fix. Existing WRN source files require no migration.
|
||||
@@ -979,7 +980,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.3.4",
|
||||
id: "component-fix",
|
||||
id: "component-fix-0-3-4",
|
||||
description: "UI component fixes.",
|
||||
apply() {
|
||||
// Compiler-only fix. Existing WRN source files require no migration.
|
||||
@@ -987,7 +988,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.3.5",
|
||||
id: "new-component-added",
|
||||
id: "new-component-added-0-3-5",
|
||||
description: "UI component Added.",
|
||||
apply() {
|
||||
// Compiler-only fix. Existing WRN source files require no migration.
|
||||
@@ -995,12 +996,95 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.3.6",
|
||||
id: "new-component-added",
|
||||
id: "new-component-added-0-3-6",
|
||||
description: "UI component Added.",
|
||||
apply() {
|
||||
// Compiler-only fix. Existing WRN source files require no migration.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.4.0",
|
||||
id: "package-runtime-and-asset-platform",
|
||||
description:
|
||||
"Enables automatic package discovery, package components/routes/migrations, and page-scoped client runtime injection without manually copied JavaScript assets.",
|
||||
apply(ctx) {
|
||||
const packageFile = join(ctx.appRoot, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as Record<string, any>;
|
||||
const scripts = (pkg.scripts ??= {});
|
||||
const additions: Record<string, string> = {
|
||||
"inspect:plugins": "wrnexus inspect plugins .",
|
||||
"inspect:runtimes": "wrnexus inspect runtimes .",
|
||||
"inspect:assets": "wrnexus inspect assets .",
|
||||
"inspect:routes": "wrnexus inspect routes .",
|
||||
};
|
||||
const addedScripts: string[] = [];
|
||||
for (const [name, command] of Object.entries(additions)) {
|
||||
if (!scripts[name]) {
|
||||
scripts[name] = command;
|
||||
addedScripts.push(name);
|
||||
}
|
||||
}
|
||||
if (addedScripts.length) ctx.log(`+ package scripts: ${addedScripts.join(", ")}`);
|
||||
if (!ctx.dryRun && addedScripts.length) {
|
||||
writeFileSync(packageFile, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
const changedFiles: string[] = [];
|
||||
const legacyCaptchaScript =
|
||||
/\s*<script\b[^>]*\bsrc\s*=\s*["']\/(?:__wrnexus\/captcha|assets\/wrnexus\/captcha)\.js(?:\?[^"']*)?["'][^>]*>(?:\s*<\/script>)?\s*/gi;
|
||||
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const after = before.replace(legacyCaptchaScript, "\n");
|
||||
if (after === before) continue;
|
||||
const relativeFile = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
||||
changedFiles.push(relativeFile);
|
||||
ctx.log(`~ removed legacy CAPTCHA script tag from ${relativeFile}`);
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
}
|
||||
|
||||
const movedAssets: string[] = [];
|
||||
for (const relativeFile of [
|
||||
"public/assets/wrnexus/captcha.js",
|
||||
"public/__wrnexus/captcha.js",
|
||||
]) {
|
||||
const source = join(ctx.appRoot, relativeFile);
|
||||
if (!existsSync(source)) continue;
|
||||
const destination = join(ctx.appRoot, ".wrnexus", "legacy-assets", "0.4.0", relativeFile);
|
||||
movedAssets.push(relativeFile);
|
||||
ctx.log(`~ archived legacy ${relativeFile}`);
|
||||
if (!ctx.dryRun) {
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
cpSync(source, destination);
|
||||
rmSync(source, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const reportFile = join(ctx.appRoot, ".wrnexus", "migrations", "0.4.0.json");
|
||||
ctx.log(
|
||||
`+ .wrnexus/migrations/0.4.0.json (${changedFiles.length} source files, ${movedAssets.length} legacy assets)`,
|
||||
);
|
||||
if (!ctx.dryRun) {
|
||||
mkdirSync(dirname(reportFile), { recursive: true });
|
||||
writeFileSync(
|
||||
reportFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: "0.4.0",
|
||||
from: ctx.from,
|
||||
appliedAt: new Date().toISOString(),
|
||||
changedFiles,
|
||||
movedAssets,
|
||||
packageRuntimeDiscovery: true,
|
||||
manualCaptchaRuntimeRequired: false,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { blockingVerificationChecks, updateApp, verificationCommands } from "../src/update.ts";
|
||||
@@ -226,3 +226,63 @@ test("update verification still blocks real doctor errors", () => {
|
||||
]),
|
||||
).toEqual([fatal]);
|
||||
});
|
||||
|
||||
test("0.4 migration removes manual CAPTCHA runtime wiring and archives copied assets", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
mkdirSync(join(root, "public", "assets", "wrnexus"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "captcha-app",
|
||||
scripts: { dev: "wrnexus dev ." },
|
||||
dependencies: { "@wrnexus/captcha": "^0.3.6" },
|
||||
wrnexus: { version: "0.3.6" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app", "pages", "index.wrn"),
|
||||
`page Home {
|
||||
view {
|
||||
<Captcha type="number" action="login" />
|
||||
<script src="/assets/wrnexus/captcha.js" defer></script>
|
||||
}
|
||||
}`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "public", "assets", "wrnexus", "captcha.js"),
|
||||
"window.legacyCaptcha = true;\n",
|
||||
);
|
||||
|
||||
try {
|
||||
updateApp(root, "0.4.0", false);
|
||||
const page = readFileSync(join(root, "app", "pages", "index.wrn"), "utf8");
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
expect(page).toContain("<Captcha");
|
||||
expect(page).not.toContain("captcha.js");
|
||||
expect(pkg.dependencies["@wrnexus/captcha"]).toBe("^0.4.0");
|
||||
expect(pkg.scripts["inspect:runtimes"]).toBe("wrnexus inspect runtimes .");
|
||||
expect(
|
||||
readFileSync(
|
||||
join(
|
||||
root,
|
||||
".wrnexus",
|
||||
"legacy-assets",
|
||||
"0.4.0",
|
||||
"public",
|
||||
"assets",
|
||||
"wrnexus",
|
||||
"captcha.js",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("legacyCaptcha");
|
||||
expect(existsSync(join(root, "public", "assets", "wrnexus", "captcha.js"))).toBe(false);
|
||||
const report = JSON.parse(
|
||||
readFileSync(join(root, ".wrnexus", "migrations", "0.4.0.json"), "utf8"),
|
||||
);
|
||||
expect(report.manualCaptchaRuntimeRequired).toBe(false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { diagnose, parse, ParseError } from "@wrnexus/syntax";
|
||||
import { generate } from "./codegen.ts";
|
||||
import type { CompileResult } from "./index.ts";
|
||||
|
||||
function compileSource(source: string, filePath: string): CompileResult {
|
||||
const richDiagnostics = diagnose(source, { file: filePath, accessibility: true });
|
||||
const errors = richDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||
if (errors.length > 0) {
|
||||
throw new ParseError(
|
||||
errors.map((diagnostic) => diagnostic.message).join("\n"),
|
||||
errors[0]!.code,
|
||||
);
|
||||
}
|
||||
|
||||
const ast = parse(source);
|
||||
return {
|
||||
code: `// compiled from .wrn\n${generate(ast)}`,
|
||||
ast,
|
||||
diagnostics: richDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`),
|
||||
richDiagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompilationCacheEntry extends CompileResult {
|
||||
key: string;
|
||||
file: string;
|
||||
sourceHash: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface CompilationCacheOptions {
|
||||
maxEntries?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface CompilationCache {
|
||||
compile(source: string, file?: string, salt?: string): CompilationCacheEntry;
|
||||
get(key: string): CompilationCacheEntry | undefined;
|
||||
invalidate(file?: string): number;
|
||||
clear(): void;
|
||||
size(): number;
|
||||
stats(): { hits: number; misses: number; entries: number };
|
||||
}
|
||||
|
||||
export function compilationKey(source: string, file = "<inline .wrn>", salt = ""): string {
|
||||
return createHash("sha256")
|
||||
.update(file)
|
||||
.update("\0")
|
||||
.update(salt)
|
||||
.update("\0")
|
||||
.update(source)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function createCompilationCache(options: CompilationCacheOptions = {}): CompilationCache {
|
||||
const maxEntries = options.maxEntries ?? 500;
|
||||
if (!Number.isInteger(maxEntries) || maxEntries < 1)
|
||||
throw new RangeError("maxEntries must be positive");
|
||||
const now = options.now ?? Date.now;
|
||||
const entries = new Map<string, CompilationCacheEntry>();
|
||||
let hits = 0;
|
||||
let misses = 0;
|
||||
|
||||
function touch(key: string, value: CompilationCacheEntry): void {
|
||||
entries.delete(key);
|
||||
entries.set(key, value);
|
||||
while (entries.size > maxEntries) entries.delete(entries.keys().next().value!);
|
||||
}
|
||||
|
||||
return {
|
||||
compile(source, file = "<inline .wrn>", salt = "") {
|
||||
const key = compilationKey(source, file, salt);
|
||||
const existing = entries.get(key);
|
||||
if (existing) {
|
||||
hits++;
|
||||
touch(key, existing);
|
||||
return existing;
|
||||
}
|
||||
misses++;
|
||||
const result = compileSource(source, file);
|
||||
const entry: CompilationCacheEntry = {
|
||||
...result,
|
||||
key,
|
||||
file,
|
||||
sourceHash: createHash("sha256").update(source).digest("hex"),
|
||||
createdAt: now(),
|
||||
};
|
||||
touch(key, entry);
|
||||
return entry;
|
||||
},
|
||||
get(key) {
|
||||
const entry = entries.get(key);
|
||||
if (entry) touch(key, entry);
|
||||
return entry;
|
||||
},
|
||||
invalidate(file) {
|
||||
let removed = 0;
|
||||
for (const [key, entry] of entries) {
|
||||
if (!file || entry.file === file) {
|
||||
entries.delete(key);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
clear() {
|
||||
entries.clear();
|
||||
},
|
||||
size: () => entries.size,
|
||||
stats: () => ({ hits, misses, entries: entries.size }),
|
||||
};
|
||||
}
|
||||
|
||||
export class DependencyGraph {
|
||||
readonly #dependencies = new Map<string, Set<string>>();
|
||||
readonly #dependents = new Map<string, Set<string>>();
|
||||
|
||||
set(file: string, dependencies: Iterable<string>): void {
|
||||
this.remove(file);
|
||||
const values = new Set(dependencies);
|
||||
this.#dependencies.set(file, values);
|
||||
for (const dependency of values) {
|
||||
const set = this.#dependents.get(dependency) ?? new Set<string>();
|
||||
set.add(file);
|
||||
this.#dependents.set(dependency, set);
|
||||
}
|
||||
}
|
||||
|
||||
remove(file: string): void {
|
||||
for (const dependency of this.#dependencies.get(file) ?? []) {
|
||||
const set = this.#dependents.get(dependency);
|
||||
set?.delete(file);
|
||||
if (set?.size === 0) this.#dependents.delete(dependency);
|
||||
}
|
||||
this.#dependencies.delete(file);
|
||||
}
|
||||
|
||||
dependencies(file: string): string[] {
|
||||
return [...(this.#dependencies.get(file) ?? [])].sort();
|
||||
}
|
||||
dependents(file: string): string[] {
|
||||
return [...(this.#dependents.get(file) ?? [])].sort();
|
||||
}
|
||||
|
||||
affected(file: string): string[] {
|
||||
const found = new Set<string>();
|
||||
const queue = [file];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
for (const dependent of this.#dependents.get(current) ?? []) {
|
||||
if (found.has(dependent)) continue;
|
||||
found.add(dependent);
|
||||
queue.push(dependent);
|
||||
}
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
}
|
||||
@@ -101,3 +101,5 @@ export function compile(source: string, filePath = "<inline .wrn>"): CompileResu
|
||||
richDiagnostics,
|
||||
};
|
||||
}
|
||||
export { compilationKey, createCompilationCache, DependencyGraph } from "./cache.ts";
|
||||
export type { CompilationCache, CompilationCacheEntry, CompilationCacheOptions } from "./cache.ts";
|
||||
|
||||
@@ -1199,7 +1199,7 @@ test("server-rendered each locals work in reactive handlers", async () => {
|
||||
expect(articles[0]!.querySelector("span")?.classList.contains("open")).toBe(false);
|
||||
|
||||
expect(articles[1]!.querySelector("span")?.classList.contains("open")).toBe(true);
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
test("WRN 0.3 metadata, computed values, loaders, and actions compile additively", () => {
|
||||
const source = `page Dashboard {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -134,3 +134,24 @@ export type { FeatureFlags, FeatureRule, FeatureValue } from "./features.ts";
|
||||
|
||||
export { checkPerformanceBudgets } from "./performance.ts";
|
||||
export type { BudgetViolation, PerformanceBudgets, PerformanceMeasurement } from "./performance.ts";
|
||||
export {
|
||||
problem,
|
||||
serviceToken,
|
||||
ServiceContainer,
|
||||
ApplicationLifecycle,
|
||||
HealthRegistry,
|
||||
requestId,
|
||||
memoryIdempotencyStore,
|
||||
withIdempotency,
|
||||
} from "./platform.ts";
|
||||
export type {
|
||||
ProblemDetails,
|
||||
ProblemDetailsInput,
|
||||
ServiceToken,
|
||||
LifecyclePhase,
|
||||
LifecycleHandler,
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
IdempotencyRecord,
|
||||
IdempotencyStore,
|
||||
} from "./platform.ts";
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface ProblemDetails {
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail?: string;
|
||||
instance?: string;
|
||||
code?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProblemDetailsInput {
|
||||
type?: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail?: string;
|
||||
instance?: string;
|
||||
code?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function problem(details: ProblemDetailsInput, headers?: HeadersInit): Response {
|
||||
const body: ProblemDetails = {
|
||||
...details,
|
||||
type: details.type ?? "about:blank",
|
||||
title: details.title,
|
||||
status: details.status,
|
||||
};
|
||||
return Response.json(body, {
|
||||
status: body.status,
|
||||
headers: {
|
||||
"content-type": "application/problem+json; charset=utf-8",
|
||||
...Object.fromEntries(new Headers(headers)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type ServiceToken<T> = string | symbol | { readonly key: symbol; readonly __type?: T };
|
||||
export function serviceToken<T>(description: string): ServiceToken<T> {
|
||||
return { key: Symbol(description) };
|
||||
}
|
||||
function tokenKey<T>(token: ServiceToken<T>): string | symbol {
|
||||
return typeof token === "object" ? token.key : token;
|
||||
}
|
||||
|
||||
export class ServiceContainer {
|
||||
readonly #values = new Map<string | symbol, unknown>();
|
||||
constructor(private readonly parent?: ServiceContainer) {}
|
||||
set<T>(token: ServiceToken<T>, value: T): this {
|
||||
this.#values.set(tokenKey(token), value);
|
||||
return this;
|
||||
}
|
||||
has<T>(token: ServiceToken<T>): boolean {
|
||||
return this.#values.has(tokenKey(token)) || !!this.parent?.has(token);
|
||||
}
|
||||
get<T>(token: ServiceToken<T>): T {
|
||||
const key = tokenKey(token);
|
||||
if (this.#values.has(key)) return this.#values.get(key) as T;
|
||||
if (this.parent) return this.parent.get(token);
|
||||
throw new Error(`WRN-SERVICE-NOT-FOUND: ${typeof key === "symbol" ? key.description : key}`);
|
||||
}
|
||||
tryGet<T>(token: ServiceToken<T>): T | undefined {
|
||||
try {
|
||||
return this.get(token);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
scope(): ServiceContainer {
|
||||
return new ServiceContainer(this);
|
||||
}
|
||||
}
|
||||
|
||||
export type LifecyclePhase = "starting" | "started" | "stopping" | "stopped";
|
||||
export type LifecycleHandler = (signal: AbortSignal) => void | Promise<void>;
|
||||
export class ApplicationLifecycle {
|
||||
readonly #handlers = new Map<LifecyclePhase, LifecycleHandler[]>();
|
||||
#controller = new AbortController();
|
||||
on(phase: LifecyclePhase, handler: LifecycleHandler): () => void {
|
||||
const handlers = this.#handlers.get(phase) ?? [];
|
||||
handlers.push(handler);
|
||||
this.#handlers.set(phase, handlers);
|
||||
return () => {
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index >= 0) handlers.splice(index, 1);
|
||||
};
|
||||
}
|
||||
async run(phase: LifecyclePhase): Promise<void> {
|
||||
if (phase === "stopping") this.#controller.abort("application stopping");
|
||||
const handlers = this.#handlers.get(phase) ?? [];
|
||||
const sequence =
|
||||
phase === "stopping" || phase === "stopped" ? [...handlers].reverse() : handlers;
|
||||
for (const handler of sequence) await handler(this.#controller.signal);
|
||||
}
|
||||
get signal(): AbortSignal {
|
||||
return this.#controller.signal;
|
||||
}
|
||||
}
|
||||
|
||||
export interface HealthCheckResult {
|
||||
status: "up" | "down" | "degraded";
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
durationMs?: number;
|
||||
}
|
||||
export type HealthCheck = () => HealthCheckResult | Promise<HealthCheckResult>;
|
||||
export class HealthRegistry {
|
||||
readonly #checks = new Map<string, HealthCheck>();
|
||||
register(name: string, check: HealthCheck): () => void {
|
||||
this.#checks.set(name, check);
|
||||
return () => this.#checks.delete(name);
|
||||
}
|
||||
async check(): Promise<{
|
||||
status: "up" | "down" | "degraded";
|
||||
checks: Record<string, HealthCheckResult>;
|
||||
}> {
|
||||
const checks: Record<string, HealthCheckResult> = {};
|
||||
for (const [name, check] of this.#checks) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
checks[name] = {
|
||||
...(await check()),
|
||||
durationMs: Math.round((performance.now() - start) * 100) / 100,
|
||||
};
|
||||
} catch (error) {
|
||||
checks[name] = {
|
||||
status: "down",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
durationMs: Math.round((performance.now() - start) * 100) / 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
const values = Object.values(checks);
|
||||
const status = values.some((item) => item.status === "down")
|
||||
? "down"
|
||||
: values.some((item) => item.status === "degraded")
|
||||
? "degraded"
|
||||
: "up";
|
||||
return { status, checks };
|
||||
}
|
||||
}
|
||||
|
||||
export function requestId(headers: Headers, preferred?: string): string {
|
||||
const existing =
|
||||
preferred ?? headers.get("x-request-id") ?? headers.get("traceparent")?.split("-")[1];
|
||||
return existing && /^[A-Za-z0-9._:-]{8,128}$/.test(existing) ? existing : randomUUID();
|
||||
}
|
||||
|
||||
export interface IdempotencyRecord<T = unknown> {
|
||||
key: string;
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
export interface IdempotencyStore<T = unknown> {
|
||||
get(key: string): Promise<IdempotencyRecord<T> | null>;
|
||||
set(record: IdempotencyRecord<T>): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
export function memoryIdempotencyStore<T = unknown>(
|
||||
now: () => number = Date.now,
|
||||
): IdempotencyStore<T> {
|
||||
const records = new Map<string, IdempotencyRecord<T>>();
|
||||
return {
|
||||
async get(key) {
|
||||
const value = records.get(key);
|
||||
if (!value) return null;
|
||||
if (value.expiresAt <= now()) {
|
||||
records.delete(key);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
async set(record) {
|
||||
records.set(record.key, record);
|
||||
},
|
||||
async delete(key) {
|
||||
records.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function withIdempotency<T>(
|
||||
store: IdempotencyStore<T>,
|
||||
key: string,
|
||||
execute: () => Promise<T>,
|
||||
ttlMs = 24 * 60 * 60 * 1000,
|
||||
): Promise<{ value: T; replayed: boolean }> {
|
||||
if (!key.trim()) throw new TypeError("idempotency key cannot be empty");
|
||||
const existing = await store.get(key);
|
||||
if (existing) return { value: existing.value, replayed: true };
|
||||
const value = await execute();
|
||||
await store.set({ key, value, expiresAt: Date.now() + ttlMs });
|
||||
return { value, replayed: false };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -85,6 +85,46 @@ export const NAV_RUNTIME = String.raw`
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function runtimeIds(root) {
|
||||
var ids = {};
|
||||
if (!root || !root.querySelectorAll) return ids;
|
||||
var nodes = [];
|
||||
if (root.matches && root.matches("[data-wrnexus-runtime]")) nodes.push(root);
|
||||
root.querySelectorAll("[data-wrnexus-runtime]").forEach(function (node) { nodes.push(node); });
|
||||
nodes.forEach(function (node) {
|
||||
String(node.getAttribute("data-wrnexus-runtime") || "")
|
||||
.split(/[\s,]+/)
|
||||
.forEach(function (id) { if (id) ids[id] = true; });
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function packageRuntimeRegistry() {
|
||||
return window.__wrnexusRuntimes || {};
|
||||
}
|
||||
|
||||
function mountPackageRuntimes(root) {
|
||||
var ids = runtimeIds(root);
|
||||
var registry = packageRuntimeRegistry();
|
||||
Object.keys(ids).forEach(function (id) {
|
||||
var runtime = registry[id];
|
||||
if (!runtime || typeof runtime.mount !== "function") return;
|
||||
try { runtime.mount(root); }
|
||||
catch (error) { console.error("[wrnexus] failed to mount runtime", id, error); }
|
||||
});
|
||||
}
|
||||
|
||||
function unmountPackageRuntimes(root) {
|
||||
var ids = runtimeIds(root);
|
||||
var registry = packageRuntimeRegistry();
|
||||
Object.keys(ids).forEach(function (id) {
|
||||
var runtime = registry[id];
|
||||
if (!runtime || typeof runtime.unmount !== "function") return;
|
||||
try { runtime.unmount(root); }
|
||||
catch (error) { console.error("[wrnexus] failed to unmount runtime", id, error); }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Append framework runtimes declared by the incoming document but not
|
||||
* currently loaded.
|
||||
@@ -106,8 +146,37 @@ export const NAV_RUNTIME = String.raw`
|
||||
var element =
|
||||
document.createElement("script");
|
||||
|
||||
Array.prototype.forEach.call(
|
||||
script.attributes,
|
||||
function (attribute) {
|
||||
if (attribute.name === "src") return;
|
||||
element.setAttribute(
|
||||
attribute.name,
|
||||
attribute.value,
|
||||
);
|
||||
},
|
||||
);
|
||||
element.src = src;
|
||||
element.async = false;
|
||||
if (!script.hasAttribute("async")) {
|
||||
element.async = false;
|
||||
}
|
||||
element.addEventListener("load", function () {
|
||||
try {
|
||||
mountPackageRuntimes(document);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(
|
||||
"wrnexus:runtime-loaded",
|
||||
{ detail: { src: src } },
|
||||
),
|
||||
);
|
||||
} catch (_) {}
|
||||
});
|
||||
element.addEventListener("error", function () {
|
||||
console.error(
|
||||
"[wrnexus] failed to load client runtime",
|
||||
src,
|
||||
);
|
||||
});
|
||||
|
||||
document.body.appendChild(element);
|
||||
});
|
||||
@@ -120,6 +189,7 @@ export const NAV_RUNTIME = String.raw`
|
||||
* disposal here guarantees that unmount hooks run before replacement.
|
||||
*/
|
||||
function dispose(root) {
|
||||
unmountPackageRuntimes(root);
|
||||
try {
|
||||
if (
|
||||
typeof window.__wrnexusDisposeBehaviors ===
|
||||
@@ -141,6 +211,7 @@ export const NAV_RUNTIME = String.raw`
|
||||
* All framework hydration functions must remain idempotent.
|
||||
*/
|
||||
function rehydrate(root) {
|
||||
mountPackageRuntimes(root);
|
||||
try {
|
||||
if (
|
||||
typeof window.__wrnexusHydrateScopes ===
|
||||
@@ -342,17 +413,6 @@ export const NAV_RUNTIME = String.raw`
|
||||
return null;
|
||||
}
|
||||
|
||||
var contentType =
|
||||
response.headers.get("content-type") ||
|
||||
"";
|
||||
|
||||
if (
|
||||
contentType.indexOf("text/html") === -1
|
||||
) {
|
||||
hardNavigate(url);
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.text().then(function (text) {
|
||||
if (inFlight !== token) {
|
||||
return;
|
||||
|
||||
@@ -104,3 +104,25 @@ test("rebinds theme controls after swapping the page", async () => {
|
||||
expect(boundRoot).toBe(win.document);
|
||||
expect(win.document.querySelector("[data-wire-theme-toggle]")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("unmounts and remounts package runtimes during client navigation", async () => {
|
||||
install(
|
||||
`<div id="app"><div data-wrnexus-runtime="captcha">Old</div><a href="/next" id="lnk">Next</a></div>`,
|
||||
);
|
||||
let mounts = 0;
|
||||
let unmounts = 0;
|
||||
win.__wrnexusRuntimes = {
|
||||
captcha: {
|
||||
mount: () => mounts++,
|
||||
unmount: () => unmounts++,
|
||||
},
|
||||
};
|
||||
nextHtml = `<html><body><div id="app"><div data-wrnexus-runtime="captcha">New</div></div></body></html>`;
|
||||
|
||||
win.document.getElementById("lnk").click();
|
||||
await flush();
|
||||
|
||||
expect(unmounts).toBe(1);
|
||||
expect(mounts).toBe(1);
|
||||
expect(win.document.getElementById("app").textContent).toContain("New");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Db, Row } from "./driver.ts";
|
||||
import type { Model } from "./schema.ts";
|
||||
import type { Dialect } from "./sql.ts";
|
||||
|
||||
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
function ident(value: string): string {
|
||||
if (!IDENT.test(value)) throw new Error(`Unsafe identifier: ${value}`);
|
||||
return value;
|
||||
}
|
||||
function ph(dialect: Dialect, index: number): string {
|
||||
return dialect === "postgres" ? `$${index}` : "?";
|
||||
}
|
||||
function encode(value: unknown): string {
|
||||
return btoa(unescape(encodeURIComponent(JSON.stringify(value))))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
function decode<T>(value: string): T {
|
||||
const pad = value.length % 4 ? "=".repeat(4 - (value.length % 4)) : "";
|
||||
return JSON.parse(
|
||||
decodeURIComponent(escape(atob(value.replace(/-/g, "+").replace(/_/g, "/") + pad))),
|
||||
) as T;
|
||||
}
|
||||
|
||||
export interface CursorPageOptions {
|
||||
limit?: number;
|
||||
after?: string;
|
||||
before?: string;
|
||||
column?: string;
|
||||
direction?: "asc" | "desc";
|
||||
maxLimit?: number;
|
||||
}
|
||||
export interface CursorPage<T> {
|
||||
items: T[];
|
||||
nextCursor?: string;
|
||||
previousCursor?: string;
|
||||
hasMore: boolean;
|
||||
}
|
||||
interface CursorValue {
|
||||
value: unknown;
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
export async function cursorPaginate<T extends Row = Row>(
|
||||
db: Db,
|
||||
query: { sql: string; params?: unknown[]; model?: Model<T> },
|
||||
options: CursorPageOptions = {},
|
||||
): Promise<CursorPage<T>> {
|
||||
const column = ident(options.column ?? "id");
|
||||
const direction = options.direction ?? "asc";
|
||||
const max = options.maxLimit ?? 100;
|
||||
const limit = Math.min(max, Math.max(1, Math.floor(options.limit ?? 20)));
|
||||
const params = [...(query.params ?? [])];
|
||||
const cursor = options.after
|
||||
? decode<CursorValue>(options.after)
|
||||
: options.before
|
||||
? decode<CursorValue>(options.before)
|
||||
: undefined;
|
||||
const comparison = options.before
|
||||
? direction === "asc"
|
||||
? "<"
|
||||
: ">"
|
||||
: direction === "asc"
|
||||
? ">"
|
||||
: "<";
|
||||
const reverse = !!options.before;
|
||||
let sql = `SELECT * FROM (${query.sql}) AS __wrn_cursor`;
|
||||
if (cursor) {
|
||||
params.push(cursor.value);
|
||||
sql += ` WHERE ${column} ${comparison} ${ph(db.driver.dialect, params.length)}`;
|
||||
}
|
||||
const order = reverse ? (direction === "asc" ? "DESC" : "ASC") : direction.toUpperCase();
|
||||
params.push(limit + 1);
|
||||
sql += ` ORDER BY ${column} ${order} LIMIT ${ph(db.driver.dialect, params.length)}`;
|
||||
let rows = await db.all<T>(sql, params, query.model);
|
||||
const hasMore = rows.length > limit;
|
||||
if (hasMore) rows = rows.slice(0, limit);
|
||||
if (reverse) rows.reverse();
|
||||
const first = rows[0]?.[column];
|
||||
const last = rows.at(-1)?.[column];
|
||||
return {
|
||||
items: rows,
|
||||
hasMore,
|
||||
...(last !== undefined && (hasMore || rows.length === limit)
|
||||
? { nextCursor: encode({ value: last, direction }) }
|
||||
: {}),
|
||||
...(first !== undefined && (options.after || options.before)
|
||||
? { previousCursor: encode({ value: first, direction }) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function optimisticUpdate(
|
||||
db: Db,
|
||||
input: {
|
||||
table: string;
|
||||
idColumn?: string;
|
||||
id: unknown;
|
||||
versionColumn?: string;
|
||||
version: number;
|
||||
values: Record<string, unknown>;
|
||||
},
|
||||
): Promise<number> {
|
||||
const table = ident(input.table);
|
||||
const idColumn = ident(input.idColumn ?? "id");
|
||||
const versionColumn = ident(input.versionColumn ?? "version");
|
||||
const entries = Object.entries(input.values);
|
||||
if (!entries.length) return input.version;
|
||||
for (const [column] of entries) ident(column);
|
||||
const params = entries.map(([, value]) => value);
|
||||
const assignments = entries.map(
|
||||
([column], index) => `${column} = ${ph(db.driver.dialect, index + 1)}`,
|
||||
);
|
||||
assignments.push(`${versionColumn} = ${versionColumn} + 1`);
|
||||
params.push(input.id, input.version);
|
||||
const result = await db.exec(
|
||||
`UPDATE ${table} SET ${assignments.join(", ")} WHERE ${idColumn} = ${ph(db.driver.dialect, params.length - 1)} AND ${versionColumn} = ${ph(db.driver.dialect, params.length)}`,
|
||||
params,
|
||||
);
|
||||
if (result.changes !== 1) throw new Error("WRN-DB-OPTIMISTIC-LOCK");
|
||||
return input.version + 1;
|
||||
}
|
||||
|
||||
export function tenantScope(
|
||||
sql: string,
|
||||
tenantId: unknown,
|
||||
dialect: Dialect,
|
||||
existingParams = 0,
|
||||
column = "tenantId",
|
||||
): { sql: string; params: unknown[] } {
|
||||
ident(column);
|
||||
const wrapped = `SELECT * FROM (${sql}) AS __wrn_tenant WHERE ${column} = ${ph(dialect, existingParams + 1)}`;
|
||||
return { sql: wrapped, params: [tenantId] };
|
||||
}
|
||||
|
||||
export function softDeleteClause(column = "deletedAt"): string {
|
||||
return `${ident(column)} IS NULL`;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
parseMigration,
|
||||
loadMigrations,
|
||||
appliedMigrations,
|
||||
applyMigrations,
|
||||
migrate,
|
||||
rollback,
|
||||
status,
|
||||
@@ -27,3 +28,5 @@ export { parseQueries, generateQueriesFile } from "./generate.ts";
|
||||
export type { QueryDef, QueryKind, ModelRef } from "./generate.ts";
|
||||
export { paginate, loadRelated } from "./query.ts";
|
||||
export type { Paginated, PageOptions, RelationOptions } from "./query.ts";
|
||||
export { cursorPaginate, optimisticUpdate, tenantScope, softDeleteClause } from "./advanced.ts";
|
||||
export type { CursorPage, CursorPageOptions } from "./advanced.ts";
|
||||
|
||||
@@ -63,21 +63,26 @@ export async function appliedMigrations(db: Db): Promise<string[]> {
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
/** Apply all pending migrations (each in a transaction). Returns applied names. */
|
||||
export async function migrate(db: Db, dir: string): Promise<string[]> {
|
||||
/** Apply an ordered migration list (each in a transaction). Returns applied names. */
|
||||
export async function applyMigrations(db: Db, migrations: readonly Migration[]): Promise<string[]> {
|
||||
const applied = new Set(await appliedMigrations(db));
|
||||
const pending = loadMigrations(dir).filter((m) => !applied.has(m.name));
|
||||
const pending = migrations.filter((migration) => !applied.has(migration.name));
|
||||
const done: string[] = [];
|
||||
for (const m of pending) {
|
||||
for (const migration of pending) {
|
||||
await db.tx(async (tx) => {
|
||||
if (m.up) await tx.exec(m.up);
|
||||
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [m.name]);
|
||||
if (migration.up) await tx.exec(migration.up);
|
||||
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
|
||||
});
|
||||
done.push(m.name);
|
||||
done.push(migration.name);
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
/** Apply all pending migrations from a directory. */
|
||||
export async function migrate(db: Db, dir: string): Promise<string[]> {
|
||||
return applyMigrations(db, loadMigrations(dir));
|
||||
}
|
||||
|
||||
/** Roll back the most recently applied migration. Returns its name, or null. */
|
||||
export async function rollback(db: Db, dir: string): Promise<string | null> {
|
||||
const applied = await appliedMigrations(db);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { UPLOAD_RUNTIME, UPLOAD_JS_HREF, UPLOADS_PREFIX, serveStoredFile } from
|
||||
import type { Mode } from "@wrnexus/core";
|
||||
import type { AssetServer } from "./runtime.ts";
|
||||
import { servePublicAsset } from "./public.ts";
|
||||
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
|
||||
|
||||
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
|
||||
export interface DevStyles {
|
||||
@@ -31,6 +32,8 @@ export interface DevStyles {
|
||||
config?: StylesConfig;
|
||||
appRoot: string;
|
||||
publicDir?: string;
|
||||
sources?: string[];
|
||||
entries?: string[];
|
||||
}
|
||||
|
||||
/** A dev asset server also supports invalidating its caches in-process. */
|
||||
@@ -64,6 +67,7 @@ export function createDevAssetServer(
|
||||
theme?: ResolvedTheme,
|
||||
uiCss?: string,
|
||||
schemasJs?: string,
|
||||
pluginAssets: readonly ServedPluginAsset[] = [],
|
||||
): DevAssetServer {
|
||||
let cssCache: string | null = null;
|
||||
let schemasCode = schemasJs ?? "window.__wireSchemas={};";
|
||||
@@ -107,16 +111,28 @@ export function createDevAssetServer(
|
||||
}
|
||||
|
||||
if (pathname === "/__wrnexus/styles.css") {
|
||||
if (!styles?.entry) return new Response("Not Found", { status: 404 });
|
||||
if (!styles?.entry && !styles?.entries?.length) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
if (cssCache === null) {
|
||||
cssCache = await renderStyles(
|
||||
{ entryPath: styles.entry, appDir, appRoot: styles.appRoot, mode },
|
||||
{
|
||||
entryPath: styles.entry,
|
||||
appDir,
|
||||
appRoot: styles.appRoot,
|
||||
mode,
|
||||
sources: styles.sources,
|
||||
entries: styles.entries,
|
||||
},
|
||||
styles.config,
|
||||
);
|
||||
}
|
||||
return cssResponse(cssCache);
|
||||
}
|
||||
|
||||
const pluginAsset = await servePluginAsset(pluginAssets, pathname, mode);
|
||||
if (pluginAsset) return pluginAsset;
|
||||
|
||||
return servePublicAsset(styles?.publicDir, pathname, mode);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* the running process while the HMR socket morphs fresh HTML into the browser.
|
||||
*/
|
||||
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { resolve, dirname, isAbsolute, join } from "node:path";
|
||||
import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core";
|
||||
import { buildRouter, type Router } from "@wrnexus/router";
|
||||
import {
|
||||
@@ -19,20 +19,22 @@ import {
|
||||
import { uiComponentsDir, uiCss } from "@wrnexus/ui";
|
||||
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
|
||||
import { loadLocales, resolveI18n, type I18nConfig } from "@wrnexus/i18n";
|
||||
import { migrate, setDb, registerDb } from "@wrnexus/db";
|
||||
import { applyMigrations, migrate, setDb, registerDb } from "@wrnexus/db";
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
import { configureStorage, type StorageConfig } from "@wrnexus/uploader";
|
||||
import { realtimeBusFromConfig } from "./realtime-bus.ts";
|
||||
import { invalidateModule, loadModule, setCompileCacheDir } from "./pipeline.ts";
|
||||
import { createHandlers, type WsData } from "./runtime.ts";
|
||||
import { createDevAssetServer } from "./assets.ts";
|
||||
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
|
||||
import { resolvePackageMigrations } from "./plugin-migrations.ts";
|
||||
import { HmrHub } from "./hmr.ts";
|
||||
import { startWatcher } from "./watch.ts";
|
||||
export { RESTART_EXIT_CODE } from "./restart.ts";
|
||||
import { resetDevCache } from "./cache.ts";
|
||||
|
||||
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
|
||||
import { createPluginRunner, type PluginInput } from "@wrnexus/plugin";
|
||||
import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin";
|
||||
import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles";
|
||||
|
||||
import { createDevToolbarCollector, type DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
|
||||
@@ -160,7 +162,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
const appDir = resolve(opts.appDir);
|
||||
const appRoot = dirname(appDir);
|
||||
const mode: Mode = opts.mode ?? "development";
|
||||
const pluginRunner = createPluginRunner(opts.plugins, {
|
||||
const discoveredPlugins = await discoverPlugins(appRoot, opts.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
const pluginRunner = createPluginRunner(discoveredPlugins, {
|
||||
root: appRoot,
|
||||
mode,
|
||||
command: "dev",
|
||||
@@ -171,13 +178,20 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
await pluginRunner.configResolved(
|
||||
Object.freeze({ ...opts }) as Readonly<Record<string, unknown>>,
|
||||
);
|
||||
const pluginContributions = await pluginRunner.contributions();
|
||||
const pluginToolbarPanels = await pluginRunner.devToolbarPanels();
|
||||
const componentDirs = [uiComponentsDir(), ...pluginContributions.componentDirs];
|
||||
|
||||
const hmr = opts.hmr ?? mode === "development";
|
||||
const port = opts.port ?? 3000;
|
||||
const hostname = opts.hostname ?? "::";
|
||||
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
|
||||
|
||||
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
|
||||
const router = buildRouter(appDir, {
|
||||
componentDirs,
|
||||
externalRoutes: pluginContributions.routes,
|
||||
middlewareFiles: pluginContributions.middleware,
|
||||
});
|
||||
const styleEntry = opts.styleEntry ?? null;
|
||||
|
||||
const devToolbarConfig = resolveDevToolbarConfig(mode, opts.devToolbar);
|
||||
@@ -215,7 +229,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
? registerDb(name, connectFromConfig(cfg, appRoot))
|
||||
: setDb(connectFromConfig(cfg, appRoot));
|
||||
const dir = name ? join(appDir, "db", name, "migrations") : join(appDir, "db", "migrations");
|
||||
const applied = await migrate(db, dir);
|
||||
const appApplied = await migrate(db, dir);
|
||||
const packageApplied = await applyMigrations(
|
||||
db,
|
||||
resolvePackageMigrations(pluginContributions.migrations, name ?? undefined),
|
||||
);
|
||||
const applied = [...appApplied, ...packageApplied];
|
||||
if (applied.length) {
|
||||
console.log(
|
||||
`[wrnexus] applied ${applied.length} migration(s)${name ? ` to '${name}'` : ""}`,
|
||||
@@ -242,10 +261,16 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
config: opts.stylesConfig,
|
||||
appRoot,
|
||||
publicDir: join(appRoot, "public"),
|
||||
sources: [
|
||||
...componentDirs,
|
||||
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
||||
],
|
||||
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
||||
},
|
||||
theme,
|
||||
uiStyles,
|
||||
schemasJs,
|
||||
pluginAssetsFromContributions(pluginContributions),
|
||||
);
|
||||
|
||||
const hub = hmr ? new HmrHub() : undefined;
|
||||
@@ -264,7 +289,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
loadModule,
|
||||
getMiddleware: middleware.load,
|
||||
assets,
|
||||
hasStyles: !!styleEntry,
|
||||
hasStyles: !!styleEntry || pluginContributions.styles.some((style) => !!style.entry),
|
||||
hasUi: true,
|
||||
theme,
|
||||
i18n,
|
||||
@@ -275,6 +300,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
security: opts.security,
|
||||
observability: opts.observability,
|
||||
tenancy: opts.tenancy,
|
||||
clientRuntimes: pluginContributions.clientRuntimes,
|
||||
hub,
|
||||
realtimeBus: realtimeBusFromConfig(opts.realtime),
|
||||
devToolbar:
|
||||
@@ -283,6 +309,31 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
config: devToolbarConfig,
|
||||
collector: devToolbarCollector,
|
||||
root: appRoot,
|
||||
panels: pluginToolbarPanels,
|
||||
platform: {
|
||||
plugins: pluginRunner.plugins.map((plugin) => ({
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
})),
|
||||
runtimes: pluginContributions.clientRuntimes.map((runtime) => ({
|
||||
id: runtime.id,
|
||||
publicPath: runtime.publicPath,
|
||||
type: runtime.type,
|
||||
load: runtime.load,
|
||||
})),
|
||||
assets: pluginContributions.assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
publicPath: asset.publicPath,
|
||||
contentType: asset.contentType,
|
||||
})),
|
||||
componentDirs,
|
||||
styles: pluginContributions.styles,
|
||||
routes: {
|
||||
pages: router.pages.length,
|
||||
api: router.api.length,
|
||||
realtime: router.realtime.length,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
@@ -304,6 +355,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
handlers,
|
||||
assets,
|
||||
devToolbarCollector,
|
||||
pluginContributions,
|
||||
});
|
||||
} catch (error) {
|
||||
server.stop();
|
||||
@@ -319,22 +371,26 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
// Allow VS Code/Bun to finish writing pasted content.
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
|
||||
|
||||
for (const relative of files) {
|
||||
invalidateModule(resolve(appDir, relative));
|
||||
for (const file of files) {
|
||||
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
|
||||
}
|
||||
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
|
||||
|
||||
Object.assign(
|
||||
router,
|
||||
buildRouter(appDir, {
|
||||
componentDirs: [uiComponentsDir()],
|
||||
componentDirs,
|
||||
externalRoutes: pluginContributions.routes,
|
||||
middlewareFiles: pluginContributions.middleware,
|
||||
}),
|
||||
);
|
||||
middleware.invalidate();
|
||||
|
||||
if (files.some((file) => file === "schemas" || file.startsWith("schemas/"))) {
|
||||
const appFiles = files.filter((file) => !isAbsolute(file));
|
||||
if (appFiles.some((file) => file === "schemas" || file.startsWith("schemas/"))) {
|
||||
assets.updateSchemas(await schemaRuntime(router));
|
||||
}
|
||||
if (files.some((file) => file === "locales" || file.startsWith("locales/"))) {
|
||||
if (appFiles.some((file) => file === "locales" || file.startsWith("locales/"))) {
|
||||
const messages = loadLocales(join(appDir, "locales"));
|
||||
runtimeDeps.i18n = Object.keys(messages).length
|
||||
? resolveI18n(messages, opts.i18n)
|
||||
@@ -350,8 +406,23 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
files,
|
||||
});
|
||||
};
|
||||
const packageWatchDirs = [
|
||||
...componentDirs,
|
||||
...pluginContributions.clientRuntimes.flatMap((runtime) =>
|
||||
runtime.entry ? [dirname(runtime.entry)] : [],
|
||||
),
|
||||
...pluginContributions.assets.flatMap((asset) => (asset.entry ? [dirname(asset.entry)] : [])),
|
||||
...pluginContributions.styles.flatMap((style) =>
|
||||
[style.source, style.entry ? dirname(style.entry) : undefined].filter(
|
||||
(value): value is string => !!value,
|
||||
),
|
||||
),
|
||||
...pluginContributions.routes.map((route) => dirname(route.entry)),
|
||||
...pluginContributions.middleware.map((file) => dirname(file)),
|
||||
];
|
||||
watcher = startWatcher({
|
||||
appDir,
|
||||
extraDirs: packageWatchDirs,
|
||||
hub,
|
||||
assets,
|
||||
devToolbarCollector,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type {
|
||||
ClientRuntimeDefinition,
|
||||
PackageAssetDefinition,
|
||||
PluginContributions,
|
||||
} from "@wrnexus/plugin";
|
||||
import { contentTypeForPath, normalizeClientRuntime, normalizePackageAsset } from "@wrnexus/plugin";
|
||||
import type { ScriptAsset } from "@wrnexus/ssr";
|
||||
|
||||
export interface ServedPluginAsset {
|
||||
id: string;
|
||||
publicPath: string;
|
||||
entry?: string;
|
||||
source?: string | Uint8Array;
|
||||
contentType: string;
|
||||
immutable: boolean;
|
||||
runtime?: boolean;
|
||||
runtimeType?: "module" | "script";
|
||||
bundle?: boolean;
|
||||
}
|
||||
|
||||
export interface ClientRuntimeAsset extends ClientRuntimeDefinition {
|
||||
publicPath: string;
|
||||
}
|
||||
|
||||
function toBody(value: Uint8Array): ArrayBuffer {
|
||||
return Uint8Array.from(value).buffer;
|
||||
}
|
||||
|
||||
export function pluginAssetsFromContributions(
|
||||
contributions: PluginContributions,
|
||||
): ServedPluginAsset[] {
|
||||
const assets: ServedPluginAsset[] = [];
|
||||
for (const input of contributions.clientRuntimes) {
|
||||
const runtime = normalizeClientRuntime(input);
|
||||
assets.push({
|
||||
id: `runtime:${runtime.id}`,
|
||||
publicPath: runtime.publicPath!,
|
||||
entry: runtime.entry,
|
||||
source: runtime.source,
|
||||
contentType: "text/javascript; charset=utf-8",
|
||||
immutable: false,
|
||||
runtime: true,
|
||||
runtimeType: runtime.type,
|
||||
bundle: runtime.bundle,
|
||||
});
|
||||
}
|
||||
for (const input of contributions.assets) {
|
||||
const asset = normalizePackageAsset(input);
|
||||
assets.push({
|
||||
id: `asset:${asset.id}`,
|
||||
publicPath: asset.publicPath!,
|
||||
entry: asset.entry,
|
||||
source: asset.source,
|
||||
contentType: asset.contentType ?? contentTypeForPath(asset.entry ?? asset.publicPath!),
|
||||
immutable: asset.immutable ?? false,
|
||||
});
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
export async function readPluginAsset(asset: ServedPluginAsset): Promise<string | ArrayBuffer> {
|
||||
if (typeof asset.source === "string") return asset.source;
|
||||
if (asset.source instanceof Uint8Array) return toBody(asset.source);
|
||||
if (!asset.entry) throw new Error(`Plugin asset '${asset.id}' has no source.`);
|
||||
|
||||
const shouldBundle = asset.runtime && (asset.bundle ?? /\.[cm]?tsx?$/.test(asset.entry));
|
||||
if (!shouldBundle) return toBody(await readFile(asset.entry));
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [asset.entry],
|
||||
target: "browser",
|
||||
format: asset.runtimeType === "script" ? "iife" : "esm",
|
||||
minify: false,
|
||||
sourcemap: "inline",
|
||||
});
|
||||
if (!result.success || !result.outputs[0]) {
|
||||
throw new Error(
|
||||
`Plugin runtime '${asset.id}' failed to build:\n${result.logs.map(String).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return toBody(new Uint8Array(await result.outputs[0].arrayBuffer()));
|
||||
}
|
||||
|
||||
export async function servePluginAsset(
|
||||
assets: readonly ServedPluginAsset[],
|
||||
pathname: string,
|
||||
mode: "development" | "production",
|
||||
): Promise<Response | null> {
|
||||
const asset = assets.find((entry) => entry.publicPath === pathname);
|
||||
if (!asset) return null;
|
||||
try {
|
||||
const body = await readPluginAsset(asset);
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": asset.contentType,
|
||||
"cache-control":
|
||||
mode === "production" && asset.immutable
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "no-cache",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[wrnexus] failed to serve plugin asset ${asset.publicPath}`, error);
|
||||
return new Response("Plugin asset unavailable", { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
export function runtimeScript(input: ClientRuntimeDefinition): ScriptAsset {
|
||||
const runtime = normalizeClientRuntime(input);
|
||||
return {
|
||||
src: runtime.publicPath!,
|
||||
type: runtime.type === "script" ? "classic" : "module",
|
||||
async: runtime.load === "eager" ? false : undefined,
|
||||
defer: runtime.load !== "eager",
|
||||
integrity: runtime.integrity,
|
||||
crossOrigin: runtime.crossOrigin,
|
||||
attributes: {
|
||||
"data-wrnexus-runtime-src": runtime.id,
|
||||
...(runtime.attributes ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function runtimeIdsFromMarkup(body: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const match of body.matchAll(/\bdata-wrnexus-runtime\s*=\s*["']([^"']+)["']/gi)) {
|
||||
for (const id of match[1]!.split(/[\s,]+/)) {
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function runtimeScriptsForMarkup(
|
||||
body: string,
|
||||
runtimes: readonly ClientRuntimeDefinition[] = [],
|
||||
): ScriptAsset[] {
|
||||
const ids = runtimeIdsFromMarkup(body);
|
||||
return runtimes.filter((runtime) => ids.has(runtime.id)).map(runtimeScript);
|
||||
}
|
||||
|
||||
export function mergePluginAssets(
|
||||
runtimes: readonly ClientRuntimeDefinition[],
|
||||
assets: readonly PackageAssetDefinition[],
|
||||
): ServedPluginAsset[] {
|
||||
return pluginAssetsFromContributions({
|
||||
componentDirs: [],
|
||||
clientRuntimes: [...runtimes],
|
||||
assets: [...assets],
|
||||
styles: [],
|
||||
routes: [],
|
||||
middleware: [],
|
||||
migrations: [],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import { loadMigrations, parseMigration, type Migration } from "@wrnexus/db";
|
||||
import type { PackageMigrationDefinition } from "@wrnexus/plugin";
|
||||
|
||||
function migrationName(definition: PackageMigrationDefinition, name?: string): string {
|
||||
const prefix = definition.id.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
||||
const suffix = name?.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
||||
return suffix ? `${prefix}__${suffix}` : prefix;
|
||||
}
|
||||
|
||||
function databaseMatches(definition: PackageMigrationDefinition, database?: string): boolean {
|
||||
const target = definition.database?.trim() || "default";
|
||||
return target === (database?.trim() || "default");
|
||||
}
|
||||
|
||||
/** Resolve package-owned migrations into the same ordered contract as app migrations. */
|
||||
export function resolvePackageMigrations(
|
||||
definitions: readonly PackageMigrationDefinition[],
|
||||
database?: string,
|
||||
): Migration[] {
|
||||
const output: Migration[] = [];
|
||||
for (const definition of definitions) {
|
||||
if (!databaseMatches(definition, database)) continue;
|
||||
if (definition.source !== undefined) {
|
||||
const parsed = parseMigration(migrationName(definition), definition.source);
|
||||
output.push(parsed);
|
||||
continue;
|
||||
}
|
||||
const entry = definition.entry;
|
||||
if (!entry || !existsSync(entry)) {
|
||||
throw new Error(
|
||||
`WRN-PLUGIN-MIGRATION-MISSING: ${definition.id} points to ${entry ?? "<empty>"}.`,
|
||||
);
|
||||
}
|
||||
const stat = statSync(entry);
|
||||
if (stat.isDirectory()) {
|
||||
for (const migration of loadMigrations(entry)) {
|
||||
output.push({ ...migration, name: migrationName(definition, migration.name) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile() || !entry.endsWith(".sql")) {
|
||||
throw new Error(
|
||||
`WRN-PLUGIN-MIGRATION-ENTRY: ${definition.id} must be a .sql file or directory.`,
|
||||
);
|
||||
}
|
||||
output.push(
|
||||
parseMigration(
|
||||
migrationName(definition, basename(entry, ".sql")),
|
||||
readFileSync(entry, "utf8"),
|
||||
),
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { realtimeBusFromConfig } from "./realtime-bus.ts";
|
||||
import { createHandlers, type AssetServer, type WsData } from "./runtime.ts";
|
||||
import { servePublicAsset } from "./public.ts";
|
||||
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
|
||||
|
||||
type RouteModule = Record<string, unknown>;
|
||||
|
||||
@@ -62,6 +63,12 @@ export interface ProdManifest {
|
||||
layouts: { name: string; mod: RouteModule }[];
|
||||
}
|
||||
|
||||
export interface ProductionPluginAsset {
|
||||
path: string;
|
||||
contentType: string;
|
||||
immutable?: boolean;
|
||||
}
|
||||
|
||||
export interface ProdOptions {
|
||||
/** Absolute path to the pre-built global stylesheet, if any. */
|
||||
stylesPath?: string;
|
||||
@@ -108,6 +115,10 @@ export interface ProdOptions {
|
||||
storage?: StorageConfig;
|
||||
/** Cache-busting version appended to framework asset URLs. */
|
||||
assetVersion?: string;
|
||||
/** Package browser runtimes already emitted by the production build. */
|
||||
clientRuntimes?: ClientRuntimeDefinition[];
|
||||
/** Public URL to emitted package asset metadata. */
|
||||
pluginAssets?: Record<string, ProductionPluginAsset>;
|
||||
/** Absolute path to copied public assets, if any. */
|
||||
publicDir?: string;
|
||||
/** Raw HTML appended to every page head. */
|
||||
@@ -238,6 +249,15 @@ function createProdAssetServer(opts: ProdOptions): AssetServer {
|
||||
if (pathname === "/__wrnexus/framework.css")
|
||||
return serveFile(opts.frameworkCssPath, CSS_HEADERS);
|
||||
if (pathname === "/__wrnexus/styles.css") return serveFile(opts.stylesPath, CSS_HEADERS);
|
||||
const pluginAsset = opts.pluginAssets?.[pathname];
|
||||
if (pluginAsset) {
|
||||
return serveFile(pluginAsset.path, {
|
||||
"content-type": pluginAsset.contentType,
|
||||
"cache-control":
|
||||
pluginAsset.immutable === false ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
}
|
||||
return servePublicAsset(opts.publicDir, pathname, MODE);
|
||||
},
|
||||
};
|
||||
@@ -306,6 +326,7 @@ export function createProductionHandlers(
|
||||
inlineStyles: opts.inlineStyles,
|
||||
stylesIncludeFramework: opts.stylesIncludeFramework,
|
||||
assetVersion: opts.assetVersion,
|
||||
clientRuntimes: opts.clientRuntimes,
|
||||
head: opts.head,
|
||||
seo: opts.seo,
|
||||
mobile: opts.mobile,
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
type TFunction,
|
||||
} from "@wrnexus/core";
|
||||
import type { Router } from "@wrnexus/router";
|
||||
import { renderDocument } from "@wrnexus/ssr";
|
||||
import { renderDocument, type RenderScript, type ScriptAsset } from "@wrnexus/ssr";
|
||||
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
|
||||
import { runtimeScriptsForMarkup } from "./plugin-assets.ts";
|
||||
import {
|
||||
THEME_COOKIE,
|
||||
THEME_CSS_HREF,
|
||||
@@ -58,7 +60,11 @@ import {
|
||||
} from "@wrnexus/i18n";
|
||||
import { runMiddleware } from "./pipeline.ts";
|
||||
import type { HmrHub } from "./hmr.ts";
|
||||
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
|
||||
import type {
|
||||
DevToolbarConfig,
|
||||
DevToolbarPanel,
|
||||
DevToolbarPlatformSnapshot,
|
||||
} from "@wrnexus/dev-toolbar/types";
|
||||
|
||||
import {
|
||||
createServerIssue,
|
||||
@@ -125,6 +131,8 @@ export interface RuntimeDeps {
|
||||
inlineStyles?: string;
|
||||
/** Production cache-busting version appended to framework asset URLs. */
|
||||
assetVersion?: string;
|
||||
/** Package browser runtimes resolved by the plugin system. */
|
||||
clientRuntimes?: ClientRuntimeDefinition[];
|
||||
/** Raw HTML appended to every page head (e.g. CDN framework links). */
|
||||
head?: string;
|
||||
/** Global SEO defaults. */
|
||||
@@ -152,6 +160,8 @@ export interface RuntimeDeps {
|
||||
config: DevToolbarConfig;
|
||||
collector: DevToolbarCollector;
|
||||
root: string;
|
||||
platform?: DevToolbarPlatformSnapshot;
|
||||
panels?: DevToolbarPanel[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -835,6 +845,8 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
collector: deps.devToolbar.collector,
|
||||
editor: deps.devToolbar.config.editor,
|
||||
allowOpenEditor: deps.devToolbar.config.openEditor !== false,
|
||||
platform: deps.devToolbar.platform,
|
||||
panels: deps.devToolbar.panels,
|
||||
});
|
||||
if (toolbarResponse) return secure(toolbarResponse);
|
||||
}
|
||||
@@ -1111,7 +1123,10 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(body, tag!, openEnd);
|
||||
i = end;
|
||||
|
||||
const component = router.components.find((c) => c.name === name);
|
||||
const normalizedName = name.toLowerCase();
|
||||
const component = router.components.find(
|
||||
(candidate) => candidate.name.toLowerCase() === normalizedName,
|
||||
);
|
||||
if (!component) {
|
||||
console.warn(`[wrnexus] no component registered for '${name}'`);
|
||||
result += body.slice(tagStart, end);
|
||||
@@ -1280,7 +1295,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
if (deps.i18n) body = translateHtml(body, ctx.t);
|
||||
|
||||
// Point 3: only ship the JS this page actually uses.
|
||||
const scripts = collectScripts(body).map((src) => versionAssetUrl(src, deps.assetVersion));
|
||||
const scripts = collectScripts(body, deps.clientRuntimes).map((script) =>
|
||||
versionRenderScript(script, deps.assetVersion),
|
||||
);
|
||||
if (pwaServiceWorkerEnabled)
|
||||
scripts.push(versionAssetUrl("/__wrnexus/pwa.js", deps.assetVersion));
|
||||
if (deps.mobile?.enabled !== false && usesMobileRuntime(body))
|
||||
@@ -1319,7 +1336,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
// Conditional GET: hash the page CONTENT (`body`), not the assembled shell —
|
||||
// the shell carries a per-request CSP nonce in dev, which would otherwise make
|
||||
// the ETag change every request. Same content → same ETag → 304 on revalidate.
|
||||
const tag = etag(`${htmlAttrs ?? ""}\n${scripts.join(",")}\n${body}`);
|
||||
const tag = etag(`${htmlAttrs ?? ""}\n${JSON.stringify(scripts)}\n${body}`);
|
||||
const method = ctx.req.method.toUpperCase();
|
||||
if ((method === "GET" || method === "HEAD") && notModified(ctx.req, tag)) {
|
||||
return new Response(null, {
|
||||
@@ -1640,10 +1657,13 @@ export function resolveTProps(
|
||||
* server-rendered into the HTML; the only script is the reactive runtime, and
|
||||
* only when the page actually contains a scope or a browser-side API fetch.
|
||||
*/
|
||||
export function collectScripts(body: string): string[] {
|
||||
export function collectScripts(
|
||||
body: string,
|
||||
clientRuntimes: readonly ClientRuntimeDefinition[] = [],
|
||||
): RenderScript[] {
|
||||
// Client-side navigation is an app-wide progressive enhancement: it must load
|
||||
// on every page (you navigate *from* any page), and degrades to full loads.
|
||||
const scripts: string[] = ["/__wrnexus/nav.js"];
|
||||
const scripts: RenderScript[] = ["/__wrnexus/nav.js"];
|
||||
if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) {
|
||||
scripts.push("/__wrnexus/reactive.js");
|
||||
}
|
||||
@@ -1667,6 +1687,10 @@ export function collectScripts(body: string): string[] {
|
||||
if (/\bdata-uploader\b/.test(body)) {
|
||||
scripts.push("/__wrnexus/uploader.js");
|
||||
}
|
||||
// Package runtimes are declarative. Components mark the rendered HTML with
|
||||
// `data-wrnexus-runtime="id"`; the corresponding package chunk is loaded
|
||||
// once, without requiring application-authored script tags or public copies.
|
||||
scripts.push(...runtimeScriptsForMarkup(body, clientRuntimes));
|
||||
return scripts;
|
||||
}
|
||||
|
||||
@@ -1686,6 +1710,11 @@ function versionAssetUrl(src: string, version?: string): string {
|
||||
return `${src}${src.includes("?") ? "&" : "?"}v=${encodeURIComponent(version)}`;
|
||||
}
|
||||
|
||||
function versionRenderScript(script: RenderScript, version?: string): RenderScript {
|
||||
if (typeof script === "string") return versionAssetUrl(script, version);
|
||||
return { ...script, src: versionAssetUrl(script.src, version) } satisfies ScriptAsset;
|
||||
}
|
||||
|
||||
function escapeStyleContent(css: string): string {
|
||||
return css.replace(/<\/style/gi, "<\\/style");
|
||||
}
|
||||
|
||||
@@ -8,13 +8,16 @@
|
||||
* process and HMR socket stay alive.
|
||||
*/
|
||||
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { existsSync, statSync, watch, type FSWatcher } from "node:fs";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type { HmrHub } from "./hmr.ts";
|
||||
import type { DevAssetServer } from "./assets.ts";
|
||||
import type { DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
|
||||
|
||||
export interface WatchOptions {
|
||||
appDir: string;
|
||||
/** Additional package component/runtime/style directories watched for HMR. */
|
||||
extraDirs?: string[];
|
||||
hub: HmrHub;
|
||||
assets: DevAssetServer;
|
||||
devToolbarCollector?: DevToolbarCollector;
|
||||
@@ -39,8 +42,12 @@ function classify(rel: string): Kind {
|
||||
return "server";
|
||||
}
|
||||
|
||||
/** Returns the watcher so the running server can close it during shutdown. */
|
||||
export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
|
||||
export interface WatchHandle {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** Returns a composite watcher so the running server can close every source root. */
|
||||
export function startWatcher(opts: WatchOptions): WatchHandle | undefined {
|
||||
const { appDir, hub, assets } = opts;
|
||||
const pending = new Set<Kind>();
|
||||
const pendingFiles = new Set<string>();
|
||||
@@ -72,18 +79,45 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
|
||||
pendingFiles.clear();
|
||||
};
|
||||
|
||||
try {
|
||||
return watch(appDir, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
const rel = filename.toString().replace(/\\/g, "/");
|
||||
if (isIgnored(rel)) return;
|
||||
pendingFiles.add(rel);
|
||||
pending.add(classify(rel));
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(flush, 200); // debounce editor write bursts
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[wrnexus] file watching unavailable; HMR disabled", err);
|
||||
return undefined;
|
||||
const appRoot = resolve(appDir);
|
||||
const candidates = [appRoot, ...(opts.extraDirs ?? []).map((dir) => resolve(dir))];
|
||||
const roots = [...new Set(candidates)]
|
||||
.filter((dir) => existsSync(dir) && statSync(dir).isDirectory())
|
||||
.filter(
|
||||
(dir, index, values) =>
|
||||
!values.some((other, otherIndex) => {
|
||||
if (otherIndex >= index) return false;
|
||||
const nested = relative(other, dir);
|
||||
return nested === "" || (!nested.startsWith("..") && !isAbsolute(nested));
|
||||
}),
|
||||
);
|
||||
const watchers: FSWatcher[] = [];
|
||||
|
||||
for (const root of roots) {
|
||||
try {
|
||||
const external = root !== appRoot;
|
||||
watchers.push(
|
||||
watch(root, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
const relativeFile = filename.toString().replace(/\\/g, "/");
|
||||
if (isIgnored(relativeFile)) return;
|
||||
const file = external ? join(root, relativeFile).replace(/\\/g, "/") : relativeFile;
|
||||
pendingFiles.add(file);
|
||||
pending.add(classify(file));
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(flush, 200); // debounce editor write bursts
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`[wrnexus] file watching unavailable for ${root}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!watchers.length) return undefined;
|
||||
return {
|
||||
close() {
|
||||
if (timer) clearTimeout(timer);
|
||||
for (const watcher of watchers) watcher.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
mergePluginAssets,
|
||||
runtimeIdsFromMarkup,
|
||||
runtimeScriptsForMarkup,
|
||||
servePluginAsset,
|
||||
} from "../src/plugin-assets.ts";
|
||||
import { resolvePackageMigrations } from "../src/plugin-migrations.ts";
|
||||
|
||||
test("injects only runtimes referenced by rendered markup", () => {
|
||||
const runtimes = [
|
||||
{ id: "captcha", source: "window.captcha = true" },
|
||||
{ id: "editor", source: "window.editor = true" },
|
||||
];
|
||||
const body = '<div data-wrnexus-runtime="captcha captcha"></div>';
|
||||
expect([...runtimeIdsFromMarkup(body)]).toEqual(["captcha"]);
|
||||
const scripts = runtimeScriptsForMarkup(body, runtimes);
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(scripts[0]).toMatchObject({
|
||||
src: "/__wrnexus/assets/captcha.js",
|
||||
type: "module",
|
||||
defer: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("serves package assets with a safe content type", async () => {
|
||||
const assets = mergePluginAssets(
|
||||
[{ id: "captcha", source: "window.captcha = true", type: "script" }],
|
||||
[],
|
||||
);
|
||||
const response = await servePluginAsset(assets, "/__wrnexus/assets/captcha.js", "development");
|
||||
expect(response?.status).toBe(200);
|
||||
expect(response?.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
expect(response?.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(await response?.text()).toContain("window.captcha");
|
||||
});
|
||||
|
||||
test("resolves inline package migrations for the requested database", () => {
|
||||
const migrations = resolvePackageMigrations(
|
||||
[
|
||||
{ id: "default-schema", source: "-- +up\nCREATE TABLE one(id INTEGER);" },
|
||||
{
|
||||
id: "analytics-schema",
|
||||
database: "analytics",
|
||||
source: "-- +up\nCREATE TABLE events(id INTEGER);",
|
||||
},
|
||||
],
|
||||
"analytics",
|
||||
);
|
||||
expect(migrations).toHaveLength(1);
|
||||
expect(migrations[0]?.name).toBe("analytics-schema");
|
||||
expect(migrations[0]?.up).toContain("CREATE TABLE events");
|
||||
});
|
||||
|
||||
import { collectScripts } from "../src/runtime.ts";
|
||||
|
||||
test("collectScripts automatically adds a referenced package runtime once", () => {
|
||||
const scripts = collectScripts(
|
||||
'<main><div data-wrnexus-runtime="captcha"></div><div data-wrnexus-runtime="captcha"></div></main>',
|
||||
[{ id: "captcha", source: "window.captcha = true", type: "script" }],
|
||||
);
|
||||
expect(
|
||||
scripts.filter(
|
||||
(script) => typeof script !== "string" && script.src === "/__wrnexus/assets/captcha.js",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DevToolbarCollector } from "./collector.ts";
|
||||
import { DEV_TOOLBAR_CSS, DEV_TOOLBAR_RUNTIME } from "../client/index.ts";
|
||||
import { openInEditor } from "./editor.ts";
|
||||
import { serializeDevToolbarJson } from "./serialize.ts";
|
||||
import type { DevToolbarPanel, DevToolbarPlatformSnapshot } from "../types.ts";
|
||||
|
||||
export interface DevToolbarRouteOptions {
|
||||
mode: string;
|
||||
@@ -9,6 +10,8 @@ export interface DevToolbarRouteOptions {
|
||||
collector: DevToolbarCollector;
|
||||
editor?: string;
|
||||
allowOpenEditor?: boolean;
|
||||
platform?: DevToolbarPlatformSnapshot;
|
||||
panels?: DevToolbarPanel[];
|
||||
}
|
||||
|
||||
const json = (value: unknown, status = 200) =>
|
||||
@@ -36,6 +39,8 @@ export async function handleDevToolbarRoute(
|
||||
return json({
|
||||
issues: options.collector.getIssues(url.searchParams.get("pathname") ?? undefined),
|
||||
});
|
||||
if (url.pathname === "/__wrnexus/dev-toolbar/platform" && request.method === "GET")
|
||||
return json({ platform: options.platform ?? null, panels: options.panels ?? [] });
|
||||
if (url.pathname === "/__wrnexus/dev-toolbar/open-editor" && request.method === "POST") {
|
||||
if (options.allowOpenEditor === false)
|
||||
return json({ error: "Open in editor is disabled." }, 403);
|
||||
|
||||
@@ -132,3 +132,24 @@ declare global {
|
||||
__wrnexusDevToolbar?: DevToolbarClientApi;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DevToolbarPanel {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
badge?: number | string;
|
||||
order?: number;
|
||||
issues?: unknown[];
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface DevToolbarPlatformSnapshot {
|
||||
plugins?: Array<{ name: string; version?: string }>;
|
||||
runtimes?: Array<{ id: string; publicPath?: string; type?: string; load?: string }>;
|
||||
assets?: Array<{ id: string; publicPath?: string; contentType?: string }>;
|
||||
componentDirs?: string[];
|
||||
styles?: unknown[];
|
||||
routes?: { pages: number; api: number; realtime: number };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -138,3 +138,5 @@ export async function deriveKey(password: string, salt: string): Promise<string>
|
||||
);
|
||||
return toB64(new Uint8Array(bits));
|
||||
}
|
||||
export { createKeyring, seal, open, sealedKeyId, needsRotation } from "./keyring.ts";
|
||||
export type { EncryptionKey, EncryptionKeyring } from "./keyring.ts";
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { decrypt, encrypt, generateKey } from "./index.ts";
|
||||
|
||||
export interface EncryptionKey {
|
||||
id: string;
|
||||
secret: string;
|
||||
active?: boolean;
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
export interface EncryptionKeyring {
|
||||
active(): EncryptionKey;
|
||||
get(id: string): EncryptionKey | undefined;
|
||||
keys(): EncryptionKey[];
|
||||
rotate(key?: EncryptionKey): Promise<EncryptionKey>;
|
||||
remove(id: string): boolean;
|
||||
}
|
||||
|
||||
const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
|
||||
function validateKey(key: EncryptionKey): void {
|
||||
if (!KEY_ID.test(key.id)) {
|
||||
throw new TypeError(`Invalid encryption key id: ${key.id}`);
|
||||
}
|
||||
if (!key.secret.trim()) {
|
||||
throw new TypeError(`Encryption key '${key.id}' has an empty secret`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createKeyring(initial: EncryptionKey[]): EncryptionKeyring {
|
||||
const values = new Map<string, EncryptionKey>();
|
||||
let activeCount = 0;
|
||||
for (const key of initial) {
|
||||
validateKey(key);
|
||||
if (values.has(key.id)) {
|
||||
throw new Error(`WRN-ENCRYPTION-KEYRING-DUPLICATE: ${key.id}`);
|
||||
}
|
||||
if (key.active) activeCount++;
|
||||
values.set(key.id, { ...key });
|
||||
}
|
||||
if (!values.size) throw new Error("WRN-ENCRYPTION-KEYRING-EMPTY");
|
||||
if (activeCount > 1) throw new Error("WRN-ENCRYPTION-KEYRING-MULTIPLE-ACTIVE");
|
||||
|
||||
const activeInternal = (): EncryptionKey => {
|
||||
const key = [...values.values()].find((entry) => entry.active) ?? [...values.values()].at(-1);
|
||||
if (!key) throw new Error("WRN-ENCRYPTION-ACTIVE-KEY-MISSING");
|
||||
return key;
|
||||
};
|
||||
|
||||
return {
|
||||
active: () => ({ ...activeInternal() }),
|
||||
get(id) {
|
||||
const key = values.get(id);
|
||||
return key ? { ...key } : undefined;
|
||||
},
|
||||
keys: () => [...values.values()].map((key) => ({ ...key })),
|
||||
async rotate(key) {
|
||||
const next = key ?? {
|
||||
id: `key-${Date.now().toString(36)}`,
|
||||
secret: await generateKey(),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
validateKey(next);
|
||||
for (const current of values.values()) current.active = false;
|
||||
const stored = { ...next, active: true };
|
||||
values.set(stored.id, stored);
|
||||
return { ...stored };
|
||||
},
|
||||
remove(id) {
|
||||
if (!values.has(id)) return false;
|
||||
if (values.size <= 1) {
|
||||
throw new Error("WRN-ENCRYPTION-KEYRING-LAST-KEY");
|
||||
}
|
||||
if (activeInternal().id === id) {
|
||||
throw new Error("WRN-ENCRYPTION-KEYRING-ACTIVE-REMOVE");
|
||||
}
|
||||
return values.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Versioned payload: `wrn1.<key-id>.<aes-gcm-payload>`. */
|
||||
export async function seal(plaintext: string, keyring: EncryptionKeyring): Promise<string> {
|
||||
const key = keyring.active();
|
||||
return `wrn1.${key.id}.${await encrypt(plaintext, key.secret)}`;
|
||||
}
|
||||
|
||||
export async function open(sealed: string, keyring: EncryptionKeyring): Promise<string> {
|
||||
const match = /^wrn1\.([A-Za-z0-9._-]{1,64})\.(.+)$/.exec(sealed);
|
||||
if (!match) throw new Error("WRN-ENCRYPTION-PAYLOAD-VERSION");
|
||||
const key = keyring.get(match[1]!);
|
||||
if (!key) throw new Error(`WRN-ENCRYPTION-KEY-NOT-FOUND: ${match[1]}`);
|
||||
return decrypt(match[2]!, key.secret);
|
||||
}
|
||||
|
||||
export function sealedKeyId(sealed: string): string | null {
|
||||
return /^wrn1\.([A-Za-z0-9._-]{1,64})\./.exec(sealed)?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function needsRotation(sealed: string, keyring: EncryptionKeyring): boolean {
|
||||
return sealedKeyId(sealed) !== keyring.active().id;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
sha256,
|
||||
hmacSign,
|
||||
hmacVerify,
|
||||
createKeyring,
|
||||
} from "../src/index.ts";
|
||||
|
||||
test("sha256 is stable and hex-encoded", async () => {
|
||||
@@ -56,3 +57,20 @@ test("deriveKey is deterministic for the same password+salt", async () => {
|
||||
// usable as an encryption key
|
||||
expect(await decrypt(await encrypt("x", k1), k1)).toBe("x");
|
||||
});
|
||||
|
||||
test("keyrings reject duplicate keys and return defensive copies", () => {
|
||||
expect(() =>
|
||||
createKeyring([
|
||||
{ id: "one", secret: "secret-one", active: true },
|
||||
{ id: "one", secret: "secret-two" },
|
||||
]),
|
||||
).toThrow("DUPLICATE");
|
||||
|
||||
const keyring = createKeyring([
|
||||
{ id: "one", secret: "secret-one", active: true },
|
||||
{ id: "two", secret: "secret-two" },
|
||||
]);
|
||||
const active = keyring.active();
|
||||
active.secret = "changed";
|
||||
expect(keyring.active().secret).toBe("secret-one");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
|
||||
@@ -145,3 +145,14 @@ export {
|
||||
currentAppOrigin,
|
||||
workspaceAppOrigins,
|
||||
} from "./workspace.ts";
|
||||
export {
|
||||
backoffDelay,
|
||||
sleep,
|
||||
retry,
|
||||
withTimeout,
|
||||
stableStringify,
|
||||
safeJsonParse,
|
||||
clamp,
|
||||
once,
|
||||
} from "./resilience.ts";
|
||||
export type { RetryOptions } from "./resilience.ts";
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
export interface RetryOptions {
|
||||
attempts?: number;
|
||||
minDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
factor?: number;
|
||||
jitter?: number;
|
||||
signal?: AbortSignal;
|
||||
retryIf?: (error: unknown, attempt: number) => boolean | Promise<boolean>;
|
||||
onRetry?: (error: unknown, attempt: number, delayMs: number) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function nonNegative(value: number, label: string): number {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new RangeError(`${label} must be a non-negative number`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function backoffDelay(
|
||||
attempt: number,
|
||||
options: Pick<RetryOptions, "minDelayMs" | "maxDelayMs" | "factor" | "jitter"> = {},
|
||||
): number {
|
||||
if (!Number.isInteger(attempt) || attempt < 1) {
|
||||
throw new RangeError("attempt must be a positive integer");
|
||||
}
|
||||
const min = nonNegative(options.minDelayMs ?? 100, "minDelayMs");
|
||||
const max = nonNegative(options.maxDelayMs ?? 30_000, "maxDelayMs");
|
||||
if (max < min) throw new RangeError("maxDelayMs must be at least minDelayMs");
|
||||
const factor = options.factor ?? 2;
|
||||
if (!Number.isFinite(factor) || factor < 1) {
|
||||
throw new RangeError("factor must be at least 1");
|
||||
}
|
||||
const jitter = Math.min(1, Math.max(0, options.jitter ?? 0.2));
|
||||
const raw = Math.min(max, min * factor ** Math.max(0, attempt - 1));
|
||||
return Math.round(raw * (1 - jitter + Math.random() * jitter * 2));
|
||||
}
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
nonNegative(ms, "sleep duration");
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
clearTimeout(timer);
|
||||
reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function retry<T>(
|
||||
operation: (attempt: number, signal?: AbortSignal) => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const attempts = options.attempts ?? 3;
|
||||
if (!Number.isInteger(attempts) || attempts < 1) {
|
||||
throw new RangeError("attempts must be a positive integer");
|
||||
}
|
||||
|
||||
let last: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
options.signal?.throwIfAborted();
|
||||
try {
|
||||
return await operation(attempt, options.signal);
|
||||
} catch (error) {
|
||||
last = error;
|
||||
const retryAllowed = options.retryIf ? await options.retryIf(error, attempt) : true;
|
||||
if (attempt >= attempts || !retryAllowed) throw error;
|
||||
const delay = backoffDelay(attempt, options);
|
||||
await options.onRetry?.(error, attempt, delay);
|
||||
await sleep(delay, options.signal);
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
export async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message = "Operation timed out",
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
nonNegative(timeoutMs, "timeoutMs");
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(new Error(message)), timeoutMs);
|
||||
const abort = (): void => controller.abort(signal?.reason);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
controller.signal.addEventListener("abort", () => reject(controller.signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
export function stableStringify(value: unknown): string {
|
||||
const ancestors = new Set<object>();
|
||||
|
||||
const normalize = (input: unknown): unknown => {
|
||||
if (!input || typeof input !== "object") return input;
|
||||
if (input instanceof Date) return input.toISOString();
|
||||
if (ancestors.has(input)) {
|
||||
throw new TypeError("Cannot stringify circular structure");
|
||||
}
|
||||
|
||||
ancestors.add(input);
|
||||
try {
|
||||
if (Array.isArray(input)) return input.map(normalize);
|
||||
const record = input as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => [key, normalize(record[key])]),
|
||||
);
|
||||
} finally {
|
||||
ancestors.delete(input);
|
||||
}
|
||||
};
|
||||
|
||||
return JSON.stringify(normalize(value));
|
||||
}
|
||||
|
||||
export function safeJsonParse<T>(value: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function once<T extends (...args: any[]) => any>(fn: T): T {
|
||||
let called = false;
|
||||
let result: ReturnType<T>;
|
||||
return ((...args: Parameters<T>) => {
|
||||
if (!called) {
|
||||
called = true;
|
||||
result = fn(...args);
|
||||
}
|
||||
return result;
|
||||
}) as T;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getOriginalRequestPath,
|
||||
getOriginalRequestUrl,
|
||||
redirectToLogin,
|
||||
stableStringify,
|
||||
} from "../src/index.ts";
|
||||
|
||||
function context(url: string, headers: HeadersInit = {}) {
|
||||
@@ -133,3 +134,13 @@ test("supports an allowed-host callback and custom response options", () => {
|
||||
expect(location.searchParams.get("tenant")).toBe("acme");
|
||||
expect(location.searchParams.get("next")).toBe("https://reports.example.test/");
|
||||
});
|
||||
|
||||
test("stableStringify permits repeated references but rejects cycles", () => {
|
||||
const shared = { value: 1 };
|
||||
expect(stableStringify({ second: shared, first: shared })).toBe(
|
||||
'{"first":{"value":1},"second":{"value":1}}',
|
||||
);
|
||||
const circular: { self?: unknown } = {};
|
||||
circular.self = circular;
|
||||
expect(() => stableStringify(circular)).toThrow("circular");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Messages, ResolvedI18n } from "./index.ts";
|
||||
|
||||
export function flattenMessages(
|
||||
messages: Messages,
|
||||
prefix = "",
|
||||
output: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
for (const [key, value] of Object.entries(messages)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === "string") output[path] = value;
|
||||
else if (value && typeof value === "object" && !Array.isArray(value))
|
||||
flattenMessages(value as Messages, path, output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function localeFallbacks(locale: string, fallback = "en"): string[] {
|
||||
const normalized = locale.replace(/_/g, "-");
|
||||
const values = [normalized];
|
||||
const base = normalized.split("-")[0]!;
|
||||
if (base !== normalized) values.push(base);
|
||||
if (!values.includes(fallback)) values.push(fallback);
|
||||
return values;
|
||||
}
|
||||
|
||||
export function translationCoverage(i18n: ResolvedI18n): Record<
|
||||
string,
|
||||
{
|
||||
translated: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
missing: string[];
|
||||
extra: string[];
|
||||
}
|
||||
> {
|
||||
const canonical = flattenMessages(i18n.messages[i18n.default] ?? {});
|
||||
const canonicalKeys = new Set(Object.keys(canonical));
|
||||
const total = canonicalKeys.size;
|
||||
const output: Record<
|
||||
string,
|
||||
{ translated: number; total: number; percentage: number; missing: string[]; extra: string[] }
|
||||
> = {};
|
||||
for (const lang of i18n.langs) {
|
||||
const messages = flattenMessages(i18n.messages[lang] ?? {});
|
||||
const keys = new Set(Object.keys(messages));
|
||||
const missing = [...canonicalKeys].filter((key) => !keys.has(key)).sort();
|
||||
const extra = [...keys].filter((key) => !canonicalKeys.has(key)).sort();
|
||||
const translated = total - missing.length;
|
||||
output[lang] = {
|
||||
translated,
|
||||
total,
|
||||
percentage: total ? Math.round((translated / total) * 10000) / 100 : 100,
|
||||
missing,
|
||||
extra,
|
||||
};
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export interface LocaleFormatter {
|
||||
number(value: number, options?: Intl.NumberFormatOptions): string;
|
||||
currency(
|
||||
value: number,
|
||||
currency: string,
|
||||
options?: Omit<Intl.NumberFormatOptions, "style" | "currency">,
|
||||
): string;
|
||||
date(value: Date | number | string, options?: Intl.DateTimeFormatOptions): string;
|
||||
relative(
|
||||
value: number,
|
||||
unit: Intl.RelativeTimeFormatUnit,
|
||||
options?: Intl.RelativeTimeFormatOptions,
|
||||
): string;
|
||||
list(values: string[], options?: Intl.ListFormatOptions): string;
|
||||
}
|
||||
|
||||
export function createLocaleFormatter(locale: string, timeZone?: string): LocaleFormatter {
|
||||
const numberCache = new Map<string, Intl.NumberFormat>();
|
||||
const dateCache = new Map<string, Intl.DateTimeFormat>();
|
||||
const key = (value: unknown) => JSON.stringify(value ?? {});
|
||||
return {
|
||||
number(value, options) {
|
||||
const cacheKey = key(options);
|
||||
let format = numberCache.get(cacheKey);
|
||||
if (!format) {
|
||||
format = new Intl.NumberFormat(locale, options);
|
||||
numberCache.set(cacheKey, format);
|
||||
}
|
||||
return format.format(value);
|
||||
},
|
||||
currency(value, currency, options) {
|
||||
return new Intl.NumberFormat(locale, { ...options, style: "currency", currency }).format(
|
||||
value,
|
||||
);
|
||||
},
|
||||
date(value, options = { dateStyle: "medium" }) {
|
||||
const resolved = { ...options, ...(timeZone ? { timeZone } : {}) };
|
||||
const cacheKey = key(resolved);
|
||||
let format = dateCache.get(cacheKey);
|
||||
if (!format) {
|
||||
format = new Intl.DateTimeFormat(locale, resolved);
|
||||
dateCache.set(cacheKey, format);
|
||||
}
|
||||
return format.format(value instanceof Date ? value : new Date(value));
|
||||
},
|
||||
relative: (value, unit, options) =>
|
||||
new Intl.RelativeTimeFormat(locale, options).format(value, unit),
|
||||
list: (values, options) => new Intl.ListFormat(locale, options).format(values),
|
||||
};
|
||||
}
|
||||
|
||||
/** Lightweight plural templates: `{count, plural, one {# item} other {# items}}`. */
|
||||
export function formatMessage(
|
||||
template: string,
|
||||
params: Record<string, string | number>,
|
||||
locale: string,
|
||||
): string {
|
||||
const plural = /\{(\w+),\s*plural,\s*one\s*\{([^{}]*)\}\s*other\s*\{([^{}]*)\}\s*\}/g;
|
||||
let result = template.replace(plural, (_match, name: string, one: string, other: string) => {
|
||||
const value = Number(params[name] ?? 0);
|
||||
const selected = new Intl.PluralRules(locale).select(value) === "one" ? one : other;
|
||||
return selected.replace(/#/g, String(value));
|
||||
});
|
||||
result = result.replace(/\{(\w+)\}/g, (_match, name: string) =>
|
||||
name in params ? String(params[name]) : `{${name}}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
@@ -170,3 +170,11 @@ export const I18N_RUNTIME = String.raw`
|
||||
else bind(document);
|
||||
})();
|
||||
`.trim();
|
||||
export {
|
||||
flattenMessages,
|
||||
localeFallbacks,
|
||||
translationCoverage,
|
||||
createLocaleFormatter,
|
||||
formatMessage,
|
||||
} from "./advanced.ts";
|
||||
export type { LocaleFormatter } from "./advanced.ts";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -33,6 +33,19 @@ export interface SignOptions {
|
||||
expiresIn?: number;
|
||||
/** Override issued-at (seconds). */
|
||||
now?: number;
|
||||
issuer?: string;
|
||||
audience?: string | string[];
|
||||
jwtId?: string;
|
||||
/** Key identifier placed in the protected header. */
|
||||
keyId?: string;
|
||||
}
|
||||
|
||||
export interface VerifyOptions {
|
||||
now?: number;
|
||||
clockTolerance?: number;
|
||||
issuer?: string;
|
||||
audience?: string | string[];
|
||||
maxAge?: number;
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
@@ -86,8 +99,19 @@ export async function signJwt(
|
||||
const now = options.now ?? Math.floor(Date.now() / 1000);
|
||||
const claims: JwtClaims = { iat: now, ...payload };
|
||||
if (options.expiresIn !== undefined) claims.exp = now + options.expiresIn;
|
||||
if (options.issuer !== undefined) claims.iss = options.issuer;
|
||||
if (options.audience !== undefined) claims.aud = options.audience;
|
||||
if (options.jwtId !== undefined) claims.jti = options.jwtId;
|
||||
|
||||
const header = b64urlEncode(enc.encode(JSON.stringify({ alg: "HS256", typ: "JWT" })));
|
||||
const header = b64urlEncode(
|
||||
enc.encode(
|
||||
JSON.stringify({
|
||||
alg: "HS256",
|
||||
typ: "JWT",
|
||||
...(options.keyId ? { kid: options.keyId } : {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const body = b64urlEncode(enc.encode(JSON.stringify(claims)));
|
||||
const data = `${header}.${body}`;
|
||||
const sig = new Uint8Array(
|
||||
@@ -100,7 +124,7 @@ export async function signJwt(
|
||||
export async function verifyJwt<T extends JwtClaims = JwtClaims>(
|
||||
token: string,
|
||||
secret: string,
|
||||
options: { now?: number } = {},
|
||||
options: VerifyOptions = {},
|
||||
): Promise<T> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) throw new JwtError("Malformed token");
|
||||
@@ -139,8 +163,30 @@ export async function verifyJwt<T extends JwtClaims = JwtClaims>(
|
||||
throw new JwtError("Invalid payload");
|
||||
}
|
||||
const now = options.now ?? Math.floor(Date.now() / 1000);
|
||||
if (typeof claims.exp === "number" && now >= claims.exp) throw new JwtError("Token expired");
|
||||
if (typeof claims.nbf === "number" && now < claims.nbf) throw new JwtError("Token not yet valid");
|
||||
const tolerance = Math.max(0, options.clockTolerance ?? 0);
|
||||
if (typeof claims.exp === "number" && now - tolerance >= claims.exp)
|
||||
throw new JwtError("Token expired");
|
||||
if (typeof claims.nbf === "number" && now + tolerance < claims.nbf)
|
||||
throw new JwtError("Token not yet valid");
|
||||
if (
|
||||
options.maxAge !== undefined &&
|
||||
typeof claims.iat === "number" &&
|
||||
now - claims.iat > options.maxAge + tolerance
|
||||
) {
|
||||
throw new JwtError("Token is too old");
|
||||
}
|
||||
if (options.issuer !== undefined && claims.iss !== options.issuer)
|
||||
throw new JwtError("Invalid issuer");
|
||||
if (options.audience !== undefined) {
|
||||
const expected = Array.isArray(options.audience) ? options.audience : [options.audience];
|
||||
const actual = Array.isArray(claims.aud)
|
||||
? claims.aud
|
||||
: typeof claims.aud === "string"
|
||||
? [claims.aud]
|
||||
: [];
|
||||
if (!expected.some((audience) => actual.includes(audience)))
|
||||
throw new JwtError("Invalid audience");
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
@@ -183,3 +229,5 @@ function bearerToken(ctx: Context): string | undefined {
|
||||
function unauthorized(): Response {
|
||||
return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
export { decodeJwt, createJwtKeyring, signWithKeyring, verifyWithKeyring } from "./keyring.ts";
|
||||
export type { JwtKey, JwtKeyring } from "./keyring.ts";
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
JwtError,
|
||||
signJwt,
|
||||
verifyJwt,
|
||||
type JwtClaims,
|
||||
type SignOptions,
|
||||
type VerifyOptions,
|
||||
} from "./index.ts";
|
||||
|
||||
export interface JwtKey {
|
||||
id: string;
|
||||
secret: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface JwtKeyring {
|
||||
active(): JwtKey;
|
||||
resolve(id: string): JwtKey | undefined;
|
||||
keys(): JwtKey[];
|
||||
}
|
||||
|
||||
const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
|
||||
function decodePart(value: string): Record<string, unknown> {
|
||||
const padding = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
|
||||
const json = atob(value.replace(/-/g, "+").replace(/_/g, "/") + padding);
|
||||
return JSON.parse(
|
||||
new TextDecoder().decode(Uint8Array.from(json, (character) => character.charCodeAt(0))),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function decodeJwt(token: string): {
|
||||
header: Record<string, unknown>;
|
||||
claims: JwtClaims;
|
||||
} {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) throw new JwtError("Malformed token");
|
||||
try {
|
||||
return {
|
||||
header: decodePart(parts[0]!),
|
||||
claims: decodePart(parts[1]!) as JwtClaims,
|
||||
};
|
||||
} catch {
|
||||
throw new JwtError("Invalid token encoding");
|
||||
}
|
||||
}
|
||||
|
||||
export function createJwtKeyring(keys: JwtKey[]): JwtKeyring {
|
||||
const values = new Map<string, JwtKey>();
|
||||
let activeCount = 0;
|
||||
for (const key of keys) {
|
||||
if (!KEY_ID.test(key.id)) throw new TypeError(`Invalid JWT key id: ${key.id}`);
|
||||
if (!key.secret.trim()) throw new TypeError(`JWT key '${key.id}' has an empty secret`);
|
||||
if (values.has(key.id)) throw new Error(`WRN-JWT-KEYRING-DUPLICATE: ${key.id}`);
|
||||
if (key.active) activeCount++;
|
||||
values.set(key.id, { ...key });
|
||||
}
|
||||
if (!values.size) throw new Error("WRN-JWT-KEYRING-EMPTY");
|
||||
if (activeCount > 1) throw new Error("WRN-JWT-KEYRING-MULTIPLE-ACTIVE");
|
||||
|
||||
const activeInternal = (): JwtKey =>
|
||||
[...values.values()].find((key) => key.active) ?? [...values.values()].at(-1)!;
|
||||
|
||||
return {
|
||||
active: () => ({ ...activeInternal() }),
|
||||
resolve(id) {
|
||||
const key = values.get(id);
|
||||
return key ? { ...key } : undefined;
|
||||
},
|
||||
keys: () => [...values.values()].map((key) => ({ ...key })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function signWithKeyring(
|
||||
claims: JwtClaims,
|
||||
keyring: JwtKeyring,
|
||||
options: SignOptions = {},
|
||||
): Promise<string> {
|
||||
const key = keyring.active();
|
||||
return signJwt(claims, key.secret, { ...options, keyId: key.id });
|
||||
}
|
||||
|
||||
export async function verifyWithKeyring<T extends JwtClaims = JwtClaims>(
|
||||
token: string,
|
||||
keyring: JwtKeyring,
|
||||
options: VerifyOptions = {},
|
||||
): Promise<T> {
|
||||
const { header } = decodeJwt(token);
|
||||
const kid = typeof header.kid === "string" ? header.kid : undefined;
|
||||
const keys = keyring.keys();
|
||||
const key = kid ? keyring.resolve(kid) : keys.length === 1 ? keys[0] : undefined;
|
||||
if (!key) {
|
||||
throw new JwtError(kid ? `Unknown key id: ${kid}` : "Token has no key id");
|
||||
}
|
||||
return verifyJwt<T>(token, key.secret, options);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import { signJwt, verifyJwt, jwtAuth, JwtError } from "../src/index.ts";
|
||||
import { createJwtKeyring, signJwt, verifyJwt, jwtAuth, JwtError } from "../src/index.ts";
|
||||
|
||||
const SECRET = "test-secret-key";
|
||||
|
||||
@@ -63,3 +63,17 @@ test("jwtAuth optional mode passes through anonymously", async () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(ctx.user).toBeUndefined();
|
||||
});
|
||||
|
||||
test("JWT keyrings reject duplicates and return defensive copies", () => {
|
||||
expect(() =>
|
||||
createJwtKeyring([
|
||||
{ id: "one", secret: "secret-one" },
|
||||
{ id: "one", secret: "secret-two" },
|
||||
]),
|
||||
).toThrow("DUPLICATE");
|
||||
|
||||
const keyring = createJwtKeyring([{ id: "one", secret: "secret-one", active: true }]);
|
||||
const key = keyring.active();
|
||||
key.secret = "changed";
|
||||
expect(keyring.active().secret).toBe("secret-one");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
export interface DeepLink {
|
||||
url: URL;
|
||||
path: string;
|
||||
query: URLSearchParams;
|
||||
}
|
||||
export function parseDeepLink(value: string, schemes: string[] = []): DeepLink | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (schemes.length && !schemes.includes(url.protocol.replace(/:$/, ""))) return null;
|
||||
return { url, path: url.pathname || "/", query: url.searchParams };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export interface OfflineTask<T = unknown> {
|
||||
id: string;
|
||||
type: string;
|
||||
payload: T;
|
||||
createdAt: number;
|
||||
attempts: number;
|
||||
}
|
||||
export interface OfflineTaskStore {
|
||||
load(): Promise<OfflineTask[]>;
|
||||
save(tasks: OfflineTask[]): Promise<void>;
|
||||
}
|
||||
export function memoryOfflineTaskStore(): OfflineTaskStore {
|
||||
let tasks: OfflineTask[] = [];
|
||||
return {
|
||||
async load() {
|
||||
return structuredClone(tasks);
|
||||
},
|
||||
async save(next) {
|
||||
tasks = structuredClone(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
export class OfflineQueue {
|
||||
readonly #handlers = new Map<string, (payload: unknown) => Promise<void>>();
|
||||
constructor(private readonly store: OfflineTaskStore = memoryOfflineTaskStore()) {}
|
||||
process<T>(type: string, handler: (payload: T) => Promise<void>): void {
|
||||
this.#handlers.set(type, handler as (payload: unknown) => Promise<void>);
|
||||
}
|
||||
async add<T>(type: string, payload: T): Promise<OfflineTask<T>> {
|
||||
const tasks = await this.store.load();
|
||||
const task = { id: crypto.randomUUID(), type, payload, createdAt: Date.now(), attempts: 0 };
|
||||
tasks.push(task);
|
||||
await this.store.save(tasks);
|
||||
return task;
|
||||
}
|
||||
async sync(limit = 20): Promise<{ completed: number; failed: number }> {
|
||||
const tasks = await this.store.load();
|
||||
const remaining: OfflineTask[] = [];
|
||||
let completed = 0,
|
||||
failed = 0;
|
||||
for (const task of tasks) {
|
||||
if (completed + failed >= limit) {
|
||||
remaining.push(task);
|
||||
continue;
|
||||
}
|
||||
const handler = this.#handlers.get(task.type);
|
||||
if (!handler) {
|
||||
remaining.push(task);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
task.attempts++;
|
||||
await handler(task.payload);
|
||||
completed++;
|
||||
} catch {
|
||||
failed++;
|
||||
remaining.push(task);
|
||||
}
|
||||
}
|
||||
await this.store.save(remaining);
|
||||
return { completed, failed };
|
||||
}
|
||||
async size(): Promise<number> {
|
||||
return (await this.store.load()).length;
|
||||
}
|
||||
}
|
||||
export interface MobileEnvironment {
|
||||
platform: string;
|
||||
native: boolean;
|
||||
online: boolean;
|
||||
userAgent?: string;
|
||||
}
|
||||
export function mobileEnvironment(): MobileEnvironment {
|
||||
const capacitor = (globalThis as any).Capacitor;
|
||||
const native = capacitor?.isNativePlatform?.() === true;
|
||||
return {
|
||||
platform: capacitor?.getPlatform?.() ?? "web",
|
||||
native,
|
||||
online: typeof navigator === "undefined" ? true : navigator.onLine,
|
||||
userAgent: typeof navigator === "undefined" ? undefined : navigator.userAgent,
|
||||
};
|
||||
}
|
||||
@@ -89,3 +89,10 @@ export const mobile = {
|
||||
invoke,
|
||||
whenNative,
|
||||
};
|
||||
export {
|
||||
parseDeepLink,
|
||||
memoryOfflineTaskStore,
|
||||
OfflineQueue,
|
||||
mobileEnvironment,
|
||||
} from "./advanced.ts";
|
||||
export type { DeepLink, OfflineTask, OfflineTaskStore, MobileEnvironment } from "./advanced.ts";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -22,3 +22,14 @@ export { browserCapabilities } from "./browser.ts";
|
||||
export { mobileCapabilities } from "./mobile.ts";
|
||||
|
||||
export const native = { isMobile, platform, register, registered, run, supports };
|
||||
export {
|
||||
defineNativeManifest,
|
||||
inspectNativeCapabilities,
|
||||
missingNativeCapabilities,
|
||||
PermissionManager,
|
||||
} from "./manifest.ts";
|
||||
export type {
|
||||
NativeCapabilityManifestEntry,
|
||||
NativeCapabilityManifest,
|
||||
PermissionAdapter,
|
||||
} from "./manifest.ts";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user