release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
View File
+2
View File
@@ -7,3 +7,5 @@ dist/
*.db-wal
.DS_Store
bun.lockb
.publish/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT",
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT",
@@ -28,18 +28,18 @@
"wrnexus": "./dist/index.js"
},
"dependencies": {
"@wrnexus/core": "^0.3.6",
"@wrnexus/router": "^0.3.6",
"@wrnexus/csr": "^0.3.6",
"@wrnexus/compiler": "^0.3.6",
"@wrnexus/styles": "^0.3.6",
"@wrnexus/dev-server": "^0.3.6",
"@wrnexus/ui": "^0.3.6",
"@wrnexus/validation": "^0.3.6",
"@wrnexus/i18n": "^0.3.6",
"@wrnexus/db": "^0.3.6",
"@wrnexus/plugin": "^0.3.6",
"@wrnexus/syntax": "^0.3.6"
"@wrnexus/core": "^0.4.0",
"@wrnexus/router": "^0.4.0",
"@wrnexus/csr": "^0.4.0",
"@wrnexus/compiler": "^0.4.0",
"@wrnexus/styles": "^0.4.0",
"@wrnexus/dev-server": "^0.4.0",
"@wrnexus/ui": "^0.4.0",
"@wrnexus/validation": "^0.4.0",
"@wrnexus/i18n": "^0.4.0",
"@wrnexus/db": "^0.4.0",
"@wrnexus/plugin": "^0.4.0",
"@wrnexus/syntax": "^0.4.0"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/syntax": "^0.3.6"
"@wrnexus/syntax": "^0.4.0"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.3.6"
"@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT",
+15 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT",
@@ -25,20 +25,20 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.3.6",
"@wrnexus/dev-toolbar": "^0.3.6",
"@wrnexus/router": "^0.3.6",
"@wrnexus/ssr": "^0.3.6",
"@wrnexus/csr": "^0.3.6",
"@wrnexus/compiler": "^0.3.6",
"@wrnexus/styles": "^0.3.6",
"@wrnexus/ui": "^0.3.6",
"@wrnexus/validation": "^0.3.6",
"@wrnexus/i18n": "^0.3.6",
"@wrnexus/db": "^0.3.6",
"@wrnexus/pubsub": "^0.3.6",
"@wrnexus/uploader": "^0.3.6",
"@wrnexus/plugin": "^0.3.6"
"@wrnexus/core": "^0.4.0",
"@wrnexus/dev-toolbar": "^0.4.0",
"@wrnexus/router": "^0.4.0",
"@wrnexus/ssr": "^0.4.0",
"@wrnexus/csr": "^0.4.0",
"@wrnexus/compiler": "^0.4.0",
"@wrnexus/styles": "^0.4.0",
"@wrnexus/ui": "^0.4.0",
"@wrnexus/validation": "^0.4.0",
"@wrnexus/i18n": "^0.4.0",
"@wrnexus/db": "^0.4.0",
"@wrnexus/pubsub": "^0.4.0",
"@wrnexus/uploader": "^0.4.0",
"@wrnexus/plugin": "^0.4.0"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-toolbar",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/dev-toolbar — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.3.6"
"@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.3.6"
"@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/native": "^0.3.6"
"@wrnexus/native": "^0.4.0"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT",
+14 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/plugin",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/plugin — part of the WrNexus framework.",
"license": "MIT",
@@ -18,10 +18,22 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./types": {
"types": "./dist/types.d.ts",
"import": "./dist/types.js"
},
"./manifest": {
"types": "./dist/manifest.d.ts",
"import": "./dist/manifest.js"
},
"./discovery": {
"types": "./dist/discovery.d.ts",
"import": "./dist/discovery.js"
}
},
"dependencies": {
"@wrnexus/syntax": "^0.3.6"
"@wrnexus/syntax": "^0.4.0"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT",
@@ -21,8 +21,8 @@
}
},
"dependencies": {
"@wrnexus/compiler": "^0.3.6",
"@wrnexus/core": "^0.3.6"
"@wrnexus/compiler": "^0.4.0",
"@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.3.6"
"@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT",
@@ -21,9 +21,9 @@
}
},
"dependencies": {
"@wrnexus/uploader": "^0.3.6",
"@wrnexus/core": "^0.3.6",
"@wrnexus/plugin": "^0.3.6"
"@wrnexus/uploader": "^0.4.0",
"@wrnexus/core": "^0.4.0",
"@wrnexus/plugin": "^0.4.0"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/syntax",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/syntax — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -36,9 +36,9 @@ Theme-aware, responsive copy markup component.
Theme-aware, responsive input number component.
- Mount: `data-component="InputNumber"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Input Number"`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "number"`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `class: string = ""`, `id: string = ""`, `name: string = "quantity"`, `value: number = 0`, `min: string = ""`, `max: string = ""`, `step: number = 1`, `precision: string = "auto"`, `label: string = ""`, `description: string = ""`, `helpText: string = ""`, `error: string = ""`, `invalid: boolean = false`, `prefix: string = ""`, `suffix: string = ""`, `placeholder: string = ""`, `autocomplete: string = "off"`, `inputMode: string = "decimal"`, `ariaLabel: string = ""`, `required: boolean = false`, `disabled: boolean = false`, `inputDisabled: boolean = false`, `buttonsDisabled: boolean = false`, `readonly: boolean = false`, `allowInput: boolean = true`, `keyboard: boolean = true`, `wheel: boolean = false`, `clamp: boolean = true`, `fullWidth: boolean = false`, `showButtons: boolean = true`, `showValidationMessage: boolean = true`, `decrementLabel: string = "Decrease value"`, `incrementLabel: string = "Increase value"`, `controlsLabel: string = "Quantity controls"`, `requiredMessage: string = "A value is required."`, `minMessage: string = "Value is below the minimum."`, `maxMessage: string = "Value is above the maximum."`
- Slots: None
- Events: None
- Events: `input`, `change`, `increment`, `decrement`
### PinInput
+14 -12
View File
@@ -15,7 +15,7 @@ driven by `var(--wire-*)` theme tokens, so components restyle instantly when the
theme changes. The tiny JS surface (`src/index.ts`) exists only so the toolchain
(CLI build + dev server) can locate the component directory and stylesheet.
The complete PDF-aligned catalog currently contains **891 components**. The
The complete PDF-aligned catalog currently contains **85 components**. The
generated `COMPONENTS.md` and `component-reference.json` files document every
mount name, prop, inferred type, default/required status, slot, event, category,
and source file directly from the packaged `.wrn` source.
@@ -35,10 +35,11 @@ In practice you rarely install this directly: `@wrnexus/cli` and
## Components
Components live as `.wrn` files under `packages/ui/components/`. The mount name
is the **lowercase file basename** (e.g. `button.wrn``data-component="button"`).
Each accepts a `class` prop (appended to its root element) and most render their
body from either a named prop or the default slot.
Components live as `.wrn` files under `packages/ui/components/`. The canonical mount
name comes from the component declaration (for example, `component Button` mounts as
`data-component="Button"`). Component lookup is case-insensitive, so existing lowercase
mounts continue to work. Each component accepts a `class` prop (appended to its root
element), and most render their body from either a named prop or the default slot.
### Layout
@@ -91,16 +92,17 @@ equivalent is exported as `@wrnexus/ui/component-reference.json`.
## API
The JS module (`@wrnexus/ui`) exposes four helpers used by the build tooling to
The JS module (`@wrnexus/ui`) exposes five helpers used by the build tooling to
locate the component assets. There is no component code to import — the components
are `.wrn` files rendered server-side.
| Export | Signature | Returns |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------ |
| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
| `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
| `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). |
| `uiComponentNames` | `() => string[]` | Sorted list of built-in component names (e.g. for `wrnexus eject` listing). |
| Export | Signature | Returns |
| ------------------ | -------------------------- | ------------------------------------------------------------------------------------------ |
| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
| `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
| `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). |
| `uiComponentNames` | `() => string[]` | Sorted list of declared built-in component names. |
| `uiComponentPath` | `(name: string) => string` | Absolute source path for a declared component name or case-insensitive alias. |
### `./ui.css` asset export
+203 -41
View File
@@ -517,7 +517,7 @@
],
"slots": ["default"],
"events": [],
"source": "components/Alert.wrn"
"source": "components/alert.wrn"
},
{
"name": "Avatar",
@@ -570,7 +570,7 @@
],
"slots": ["default"],
"events": [],
"source": "components/Avatar.wrn"
"source": "components/avatar.wrn"
},
{
"name": "AvatarGroup",
@@ -676,7 +676,7 @@
],
"slots": ["default"],
"events": [],
"source": "components/Badge.wrn"
"source": "components/badge.wrn"
},
{
"name": "Blockquote",
@@ -943,7 +943,7 @@
],
"slots": ["default"],
"events": [],
"source": "components/Button.wrn"
"source": "components/button.wrn"
},
{
"name": "ButtonGroup",
@@ -1049,7 +1049,7 @@
],
"slots": ["default"],
"events": [],
"source": "components/Card.wrn"
"source": "components/card.wrn"
},
{
"name": "Carousel",
@@ -1267,7 +1267,7 @@
],
"slots": [],
"events": [],
"source": "components/Checkbox.wrn"
"source": "components/checkbox.wrn"
},
{
"name": "Clipboard",
@@ -1754,7 +1754,7 @@
],
"slots": [],
"events": ["search", "select", "change", "clear", "open", "close", "load", "error"],
"source": "components/ComboBox.wrn"
"source": "components/Combobox.wrn"
},
{
"name": "Confetti",
@@ -1854,7 +1854,7 @@
],
"slots": ["default"],
"events": [],
"source": "components/Container.wrn"
"source": "components/container.wrn"
},
{
"name": "ContextMenu",
@@ -2338,7 +2338,7 @@
],
"slots": [],
"events": [],
"source": "components/Divider.wrn"
"source": "components/divider.wrn"
},
{
"name": "DragAndDrop",
@@ -2751,7 +2751,7 @@
],
"slots": ["default"],
"events": [],
"source": "components/Grid.wrn"
"source": "components/grid.wrn"
},
{
"name": "Image",
@@ -2905,7 +2905,7 @@
],
"slots": [],
"events": [],
"source": "components/Input.wrn"
"source": "components/input.wrn"
},
{
"name": "InputGroup",
@@ -3015,34 +3015,34 @@
"default": "\"primary\""
},
{
"name": "label",
"name": "variant",
"type": "string",
"required": false,
"default": "\"Input Number\""
"default": "\"default\""
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "id",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "name",
"type": "string",
"required": false,
"default": "\"\""
"default": "\"quantity\""
},
{
"name": "value",
"type": "string",
"type": "number",
"required": false,
"default": "\"\""
},
{
"name": "placeholder",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "type",
"type": "string",
"required": false,
"default": "\"number\""
"default": "0"
},
{
"name": "min",
@@ -3058,16 +3058,82 @@
},
{
"name": "step",
"type": "number",
"required": false,
"default": "1"
},
{
"name": "precision",
"type": "string",
"required": false,
"default": "\"auto\""
},
{
"name": "label",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "disabled",
"name": "description",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "helpText",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "error",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "invalid",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "prefix",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "suffix",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "placeholder",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "autocomplete",
"type": "string",
"required": false,
"default": "\"off\""
},
{
"name": "inputMode",
"type": "string",
"required": false,
"default": "\"decimal\""
},
{
"name": "ariaLabel",
"type": "string",
"required": false,
"default": "\"\""
},
{
"name": "required",
"type": "boolean",
@@ -3075,14 +3141,110 @@
"default": "false"
},
{
"name": "class",
"name": "disabled",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "inputDisabled",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "buttonsDisabled",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "readonly",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "allowInput",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "keyboard",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "wheel",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "clamp",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "fullWidth",
"type": "boolean",
"required": false,
"default": "false"
},
{
"name": "showButtons",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "showValidationMessage",
"type": "boolean",
"required": false,
"default": "true"
},
{
"name": "decrementLabel",
"type": "string",
"required": false,
"default": "\"\""
"default": "\"Decrease value\""
},
{
"name": "incrementLabel",
"type": "string",
"required": false,
"default": "\"Increase value\""
},
{
"name": "controlsLabel",
"type": "string",
"required": false,
"default": "\"Quantity controls\""
},
{
"name": "requiredMessage",
"type": "string",
"required": false,
"default": "\"A value is required.\""
},
{
"name": "minMessage",
"type": "string",
"required": false,
"default": "\"Value is below the minimum.\""
},
{
"name": "maxMessage",
"type": "string",
"required": false,
"default": "\"Value is above the maximum.\""
}
],
"slots": [],
"events": [],
"events": ["input", "change", "increment", "decrement"],
"source": "components/InputNumber.wrn"
},
{
@@ -4039,7 +4201,7 @@
],
"slots": [],
"events": [],
"source": "components/Progress.wrn"
"source": "components/progress.wrn"
},
{
"name": "Radio",
@@ -4098,7 +4260,7 @@
],
"slots": [],
"events": [],
"source": "components/Radio.wrn"
"source": "components/radio.wrn"
},
{
"name": "RangeSlider",
@@ -4453,7 +4615,7 @@
],
"slots": [],
"events": [],
"source": "components/Select.wrn"
"source": "components/select.wrn"
},
{
"name": "Sidebar",
@@ -4547,7 +4709,7 @@
],
"slots": [],
"events": [],
"source": "components/Skeleton.wrn"
"source": "components/skeleton.wrn"
},
{
"name": "Spinner",
@@ -4588,7 +4750,7 @@
],
"slots": [],
"events": [],
"source": "components/Spinner.wrn"
"source": "components/spinner.wrn"
},
{
"name": "Stepper",
@@ -4926,7 +5088,7 @@
],
"slots": [],
"events": [],
"source": "components/Switch.wrn"
"source": "components/switch.wrn"
},
{
"name": "Table",
@@ -4979,7 +5141,7 @@
],
"slots": ["default"],
"events": [],
"source": "components/Table.wrn"
"source": "components/table.wrn"
},
{
"name": "Tabs",
@@ -5103,7 +5265,7 @@
],
"slots": [],
"events": [],
"source": "components/Textarea.wrn"
"source": "components/textarea.wrn"
},
{
"name": "Timeline",
@@ -5654,7 +5816,7 @@
],
"slots": ["default"],
"events": [],
"source": "components/Tooltip.wrn"
"source": "components/tooltip.wrn"
},
{
"name": "TreeView",
+621 -18
View File
@@ -1,20 +1,623 @@
component InputNumber {
props {
size = "default"
color = "primary"
label = "Input Number"
name = ""
value = ""
placeholder = ""
type = "number"
min = ""
max = ""
step = ""
disabled = false
required = false
class = ""
}
view {
<label class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--input-number {class}"><span>{label}</span><input {...attrs} type="{type}" name="{name}" value="{value}" placeholder="{placeholder}" min="{min}" max="{max}" step="{step}" disabled="{disabled}" required="{required}" /></label>
}
props {
size = "default"
color = "primary"
variant = "default"
class = ""
id = ""
name = "quantity"
value = 0
min = ""
max = ""
step = 1
precision = "auto"
label = ""
description = ""
helpText = ""
error = ""
invalid = false
prefix = ""
suffix = ""
placeholder = ""
autocomplete = "off"
inputMode = "decimal"
ariaLabel = ""
required = false
disabled = false
inputDisabled = false
buttonsDisabled = false
readonly = false
allowInput = true
keyboard = true
wheel = false
clamp = true
fullWidth = false
showButtons = true
showValidationMessage = true
decrementLabel = "Decrease value"
incrementLabel = "Increase value"
controlsLabel = "Quantity controls"
requiredMessage = "A value is required."
minMessage = "Value is below the minimum."
maxMessage = "Value is above the maximum."
@event input = function
@event change = function
@event increment = function
@event decrement = function
}
state currentValue = value
state committedValue = value
functions {
function inputId() {
if (id !== "") {
return id
}
if (name !== "") {
return name
}
return "input-number"
}
function descriptionId() {
return inputId() + "-description"
}
function messageId() {
return inputId() + "-message"
}
function componentColor() {
if (color === "secondary") {
return "var(--wire-color-secondary)"
}
if (color === "success") {
return "var(--wire-color-success)"
}
if (color === "warning") {
return "var(--wire-color-warning)"
}
if (color === "danger") {
return "var(--wire-color-danger)"
}
if (color === "info") {
return "var(--wire-color-info)"
}
return "var(--wire-color-primary)"
}
function hasMin() {
return min !== "" && min !== null && min !== undefined
}
function hasMax() {
return max !== "" && max !== null && max !== undefined
}
function isBlank() {
return currentValue === "" || currentValue === null || currentValue === undefined
}
function normalizedStep() {
return Number(step) > 0 ? Number(step) : 1
}
function inferredPrecision() {
if (precision !== "auto" && precision !== "") {
return Math.max(0, Number(precision) || 0)
}
if (String(normalizedStep()).includes(".")) {
return String(normalizedStep()).split(".")[1].length
}
return 0
}
function precisionFactor() {
return Math.pow(10, inferredPrecision())
}
function roundValue(nextValue) {
return Math.round(Number(nextValue) * precisionFactor()) / precisionFactor()
}
function clampValue(nextValue) {
nextValue = Number(nextValue)
if (hasMin() && nextValue < Number(min)) {
nextValue = Number(min)
}
if (hasMax() && nextValue > Number(max)) {
nextValue = Number(max)
}
return roundValue(nextValue)
}
function isBelowMin() {
return !isBlank() && hasMin() && Number(currentValue) < Number(min)
}
function isAboveMax() {
return !isBlank() && hasMax() && Number(currentValue) > Number(max)
}
function isInvalid() {
return (
invalid ||
error !== "" ||
(required && isBlank()) ||
isBelowMin() ||
isAboveMax()
)
}
function validationMessage() {
if (error !== "") {
return error
}
if (required && isBlank()) {
return requiredMessage
}
if (isBelowMin()) {
return minMessage
}
if (isAboveMax()) {
return maxMessage
}
return ""
}
function hasMessage() {
return (
helpText !== "" ||
(showValidationMessage && isInvalid() && validationMessage() !== "")
)
}
function describedBy() {
if (description !== "" && hasMessage()) {
return descriptionId() + " " + messageId()
}
if (description !== "") {
return descriptionId()
}
if (hasMessage()) {
return messageId()
}
return ""
}
function decrementDisabled() {
return (
disabled ||
readonly ||
buttonsDisabled ||
(hasMin() && !isBlank() && Number(currentValue) <= Number(min))
)
}
function incrementDisabled() {
return (
disabled ||
readonly ||
buttonsDisabled ||
(hasMax() && !isBlank() && Number(currentValue) >= Number(max))
)
}
function dispatchInputNumberEvent(
sourceEvent,
eventName,
action,
previousValue,
root,
customEvent
) {
root = sourceEvent.currentTarget.closest("[data-wrn-input-number]")
if (!root && sourceEvent.target) {
root = sourceEvent.target.closest("[data-wrn-input-number]")
}
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent(eventName, true, false, {
component: "InputNumber",
name: name,
value: currentValue,
previousValue: previousValue,
action: action,
min: hasMin() ? Number(min) : null,
max: hasMax() ? Number(max) : null,
step: normalizedStep(),
valid: !isInvalid()
})
root.dispatchEvent(customEvent)
}
function applyControlValue(nextValue, action, sourceEvent, previousValue) {
previousValue = currentValue
currentValue = clampValue(nextValue)
committedValue = currentValue
dispatchInputNumberEvent(
sourceEvent,
"input",
action,
previousValue
)
dispatchInputNumberEvent(
sourceEvent,
"change",
action,
previousValue
)
dispatchInputNumberEvent(
sourceEvent,
action,
action,
previousValue
)
}
function incrementValue(sourceEvent, nextValue) {
if (incrementDisabled()) {
return
}
if (isBlank()) {
nextValue = hasMin() ? Number(min) : normalizedStep()
} else {
nextValue =
Number(currentValue) +
normalizedStep() * (sourceEvent.shiftKey ? 10 : 1)
}
applyControlValue(nextValue, "increment", sourceEvent)
}
function decrementValue(sourceEvent, nextValue) {
if (decrementDisabled()) {
return
}
if (isBlank()) {
nextValue = hasMax() ? Number(max) : -normalizedStep()
} else {
nextValue =
Number(currentValue) -
normalizedStep() * (sourceEvent.shiftKey ? 10 : 1)
}
applyControlValue(nextValue, "decrement", sourceEvent)
}
function handleInput(sourceEvent, previousValue, nextValue) {
sourceEvent.stopPropagation()
previousValue = currentValue
nextValue = sourceEvent.target.value
if (nextValue === "") {
currentValue = ""
} else if (!Number.isNaN(Number(nextValue))) {
currentValue = roundValue(Number(nextValue))
}
dispatchInputNumberEvent(
sourceEvent,
"input",
"input",
previousValue
)
}
function handleChange(sourceEvent, previousValue) {
sourceEvent.stopPropagation()
previousValue = committedValue
if (!isBlank()) {
currentValue = clamp
? clampValue(currentValue)
: roundValue(currentValue)
}
committedValue = currentValue
dispatchInputNumberEvent(
sourceEvent,
"change",
"change",
previousValue
)
}
function handleKeydown(sourceEvent) {
if (
!keyboard ||
disabled ||
readonly ||
inputDisabled ||
!allowInput
) {
return
}
if (sourceEvent.key === "ArrowUp") {
sourceEvent.preventDefault()
incrementValue(sourceEvent)
} else if (sourceEvent.key === "ArrowDown") {
sourceEvent.preventDefault()
decrementValue(sourceEvent)
} else if (sourceEvent.key === "Home" && hasMin()) {
sourceEvent.preventDefault()
applyControlValue(Number(min), "decrement", sourceEvent)
} else if (sourceEvent.key === "End" && hasMax()) {
sourceEvent.preventDefault()
applyControlValue(Number(max), "increment", sourceEvent)
}
}
function handleWheel(sourceEvent) {
if (
!wheel ||
disabled ||
readonly ||
inputDisabled ||
!allowInput
) {
return
}
sourceEvent.preventDefault()
if (sourceEvent.deltaY < 0) {
incrementValue(sourceEvent)
} else if (sourceEvent.deltaY > 0) {
decrementValue(sourceEvent)
}
}
}
view {
<div
{...attrs}
data-wrn-input-number
data-variant='{variant}'
data-size='{size}'
data-color='{color}'
data-value='{currentValue}'
data-invalid='{isInvalid() ? "true" : "false"}'
data-disabled='{disabled ? "true" : "false"}'
style='--input-number-accent: {componentColor()};'
class='relative flex flex-col gap-1.5 text-[var(--wire-color-text)] {fullWidth ? "w-full" : variant === "compact" ? "w-fit max-w-full" : "w-full max-w-sm"} {disabled ? "opacity-60" : ""} {class}'
>
{#if label !== "" && variant !== "labeled" && variant !== "seat"}
<label
for='{inputId()}'
class='inline-flex items-center gap-1 text-sm font-semibold leading-5 text-[var(--wire-color-text)]'
>
<span>{label}</span>
{#if required}
<span
aria-hidden="true"
class='text-[var(--wire-color-danger)]'
>*</span>
{/if}
</label>
{/if}
{#if description !== "" && variant !== "labeled" && variant !== "seat"}
<p
id='{descriptionId()}'
class='m-0 text-xs leading-5 text-[var(--wire-color-muted)]'
>
{description}
</p>
{/if}
<div
class='group flex min-w-0 overflow-hidden border bg-[var(--wire-color-surface)] text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] transition-[border-color,box-shadow,background-color] duration-[var(--wire-motion-base)] ease-[var(--wire-ease-standard)] focus-within:border-[var(--input-number-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--input-number-accent)_18%,transparent)] {variant === "compact" ? "rounded-full" : "rounded-[var(--wire-radius-sm)]"} {size === "xs" ? "min-h-8 text-xs" : size === "sm" ? "min-h-9 text-sm" : size === "lg" ? "min-h-12 text-base" : size === "xl" ? "min-h-14 text-lg" : "min-h-10 text-sm"} {isInvalid() ? "border-[var(--wire-color-danger)] focus-within:border-[var(--wire-color-danger)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--wire-color-danger)_18%,transparent)]" : "border-[var(--wire-color-border)]"} {disabled ? "cursor-not-allowed bg-[var(--wire-color-surface-2)]" : ""}'
>
{#if variant === "horizontal" && showButtons}
<button
type="button"
aria-label='{decrementLabel}'
aria-controls='{inputId()}'
disabled='{decrementDisabled()}'
@click='decrementValue(event)'
class='inline-flex shrink-0 items-center justify-center border-r border-[var(--wire-color-border)] bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
>
<span
aria-hidden="true"
class='icon-[lucide--minus] size-4'
></span>
</button>
{/if}
<div
class='flex min-w-0 flex-1 {variant === "labeled" ? "flex-col items-stretch justify-center gap-0.5" : variant === "seat" ? "items-center justify-between gap-3" : "items-center"} {size === "xs" ? "px-2" : size === "sm" ? "px-2.5" : size === "lg" ? "px-4" : size === "xl" ? "px-5" : "px-3"}'
>
{#if variant === "labeled" || variant === "seat"}
<div class='min-w-0 flex-1'>
{#if label !== ""}
<label
for='{inputId()}'
class='block truncate font-semibold leading-4 text-[var(--wire-color-text)] {variant === "labeled" ? "text-xs font-medium text-[var(--wire-color-muted)]" : "text-sm"}'
>
{label}
{#if required}
<span
aria-hidden="true"
class='ml-0.5 text-[var(--wire-color-danger)]'
>*</span>
{/if}
</label>
{/if}
{#if description !== ""}
<span
id='{descriptionId()}'
class='block truncate text-xs leading-4 text-[var(--wire-color-muted)]'
>
{description}
</span>
{/if}
</div>
{/if}
<div
class='flex min-w-0 items-center {variant === "seat" ? "w-auto shrink-0" : "w-full"}'
>
{#if prefix !== ""}
<span
aria-hidden="true"
class='shrink-0 pr-1.5 text-[var(--wire-color-muted)]'
>
{prefix}
</span>
{/if}
<input
id='{inputId()}'
name='{name}'
type="number"
value='{currentValue}'
min='{min}'
max='{max}'
step='{normalizedStep()}'
placeholder='{placeholder}'
autocomplete='{autocomplete}'
inputmode='{inputMode}'
aria-label='{ariaLabel !== "" ? ariaLabel : label !== "" ? label : name}'
aria-describedby='{describedBy()}'
aria-invalid='{isInvalid() ? "true" : "false"}'
aria-required='{required ? "true" : "false"}'
aria-disabled='{disabled || inputDisabled ? "true" : "false"}'
required='{required}'
disabled='{disabled}'
readonly='{readonly || inputDisabled || !allowInput}'
tabindex='{inputDisabled ? "-1" : "0"}'
@input='handleInput(event)'
@change='handleChange(event)'
@keydown='handleKeydown(event)'
@wheel='handleWheel(event)'
class='min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 font-medium leading-none text-[var(--wire-color-text)] outline-none placeholder:text-[var(--wire-color-muted)] read-only:cursor-default disabled:cursor-not-allowed [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none {variant === "horizontal" || variant === "compact" || variant === "seat" ? "text-center" : "text-left"} {variant === "seat" ? "w-10 flex-none" : "w-full"}'
/>
{#if suffix !== ""}
<span
aria-hidden="true"
class='shrink-0 pl-1.5 text-[var(--wire-color-muted)]'
>
{suffix}
</span>
{/if}
</div>
</div>
{#if variant === "horizontal" && showButtons}
<button
type="button"
aria-label='{incrementLabel}'
aria-controls='{inputId()}'
disabled='{incrementDisabled()}'
@click='incrementValue(event)'
class='inline-flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
>
<span
aria-hidden="true"
class='icon-[lucide--plus] size-4'
></span>
</button>
{:else}
{#if showButtons}
<div
role="group"
aria-label='{controlsLabel}'
class='flex shrink-0 border-l border-[var(--wire-color-border)] {variant === "vertical" ? "flex-col" : "flex-row"}'
>
<button
type="button"
aria-label='{decrementLabel}'
aria-controls='{inputId()}'
disabled='{decrementDisabled()}'
@click='decrementValue(event)'
class='inline-flex items-center justify-center bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {variant === "vertical" ? "flex-1 border-b border-[var(--wire-color-border)]" : "border-r border-[var(--wire-color-border)]"} {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
>
<span
aria-hidden="true"
class='icon-[lucide--minus] size-4'
></span>
</button>
<button
type="button"
aria-label='{incrementLabel}'
aria-controls='{inputId()}'
disabled='{incrementDisabled()}'
@click='incrementValue(event)'
class='inline-flex items-center justify-center bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {variant === "vertical" ? "flex-1" : ""} {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
>
<span
aria-hidden="true"
class='icon-[lucide--plus] size-4'
></span>
</button>
</div>
{/if}
{/if}
</div>
{#if showValidationMessage && isInvalid() && validationMessage() !== ""}
<p
id='{messageId()}'
role="alert"
aria-live="polite"
class='m-0 flex items-center gap-1.5 text-xs leading-5 text-[var(--wire-color-danger)]'
>
<span
aria-hidden="true"
class='icon-[lucide--circle-alert] size-3.5 shrink-0'
></span>
<span>{validationMessage()}</span>
</p>
{/if}
{#if (!showValidationMessage || !isInvalid() || validationMessage() === "") && helpText !== ""}
<p
id='{messageId()}'
class='m-0 text-xs leading-5 text-[var(--wire-color-muted)]'
>
{helpText}
</p>
{/if}
</div>
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT",
@@ -26,7 +26,7 @@
"./ui.css": "./ui.css"
},
"dependencies": {
"@wrnexus/core": "^0.3.6"
"@wrnexus/core": "^0.4.0"
},
"files": [
"dist",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.3.6"
"@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT",
-51
View File
@@ -1,51 +0,0 @@
# WRNexusJS CAPTCHA audio playback fix
This patch fixes audio CAPTCHA playback in production and bundled server builds.
## Root cause
`AssetAudioRenderer` originally located WAV files only from `import.meta.url`. After
WRNexusJS bundles the server into `dist/server.js`, that URL points to the app build
folder instead of `packages/captcha`, so `/api/captcha/audio/...` fails while trying
to read the WAV clips.
## Changes
- Resolves audio assets from the installed `@wrnexus/captcha/audio` package entry.
- Supports `WRNEXUS_CAPTCHA_AUDIO_DIR` for custom deployments.
- Searches workspace and `node_modules` layouts as safe fallbacks.
- Keeps the browser `Audio` instance alive while it plays.
- Stops old audio when a new challenge is loaded.
- Returns a clear HTTP 503 and server log if audio rendering fails.
- Adds a real WAV rendering test.
## Apply
Extract this ZIP into the WRNexusJS repository root and replace the included files.
Then run:
```bash
bun run typecheck
bun test packages/captcha
bun run --cwd examples/captcha-showcase check
```
Restart the server. If running the production build, rebuild first:
```bash
bun run --cwd examples/captcha-showcase build
bun examples/captcha-showcase/dist/server.js
```
## Verify
1. Open the browser Network panel.
2. Click **Listen**.
3. The `/api/captcha/audio/<id>?key=...` request must return HTTP 200.
4. Its `content-type` must be `audio/wav`.
For a nonstandard package layout, set an absolute path before starting the server:
```powershell
$env:WRNEXUS_CAPTCHA_AUDIO_DIR="E:\WireJS\packages\captcha\assets\audio"
```
-40
View File
@@ -1,40 +0,0 @@
# WRNexusJS CAPTCHA Fixed 8
Extract this ZIP directly into the WRNexusJS repository root and allow the included files to replace the current CAPTCHA files.
## Added
- 18 concrete generated CAPTCHA image styles
- `imageStyle="random"`
- `allowedStyles` and `excludedStyles`
- `randomizeStyle`
- Resolved style metadata and browser event details
- Easy, normal, hard, controlled-pool, and full renderer showcase at `/styles`
- Tailwind Iconify/Lucide showcase dependencies
- Faster CRC32 PNG encoding
- Safe floating-point line rendering for web, darts, distortion, wave, and scribble effects
- Tests for every renderer and style-pool rule
## Supported styles
`classic`, `collision`, `snow`, `corrosion`, `spiderweb`, `cross-shadow`, `split`, `split2`, `cut`, `darts`, `distortion`, `stitch`, `striped`, `wave`, `grid-noise`, `scribble`, `pixel`, `broken-lines`, and `random`.
## After extraction
```bash
bun install
bun run scripts/generate-ui-component-reference.mjs
bun run typecheck
bun test packages/captcha
bun test services/managed-captcha
bun run --cwd examples/captcha-showcase check
bun run --cwd examples/captcha-showcase dev
```
Open:
```text
http://localhost:3000/styles
```
Use a hard browser refresh after restarting the development server.
-51
View File
@@ -1,51 +0,0 @@
# WRNexusJS CAPTCHA Fixed 7
Extract this patch directly into the WRNexusJS repository root and allow files to be replaced.
## Added
- `size="compact"`, `size="normal"`, and `size="big"`
- Backward-compatible `small`/`sm`, `large`/`lg`, and `compact="true"` aliases
- `showListen="true|false"` for the Listen button
- Existing `showAudio="true|false"` remains the broader audio-alternative switch
- Self-hosted `type="not-robot"` checkbox challenge
- Showcase examples and tests for all new behavior
- Managed API/OpenAPI support for `not-robot`
## Usage
```wrn
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="number"
action="compact-example"
size="compact"
showListen="false"
/>
```
```wrn
<Captcha
endpoint="/api/captcha/challenge"
verifyEndpoint="/api/captcha/verify"
type="not-robot"
action="contact-submit"
size="compact"
showListen="false"
/>
```
The not-robot checkbox is server verified. It uses a one-time challenge, minimum completion time, honeypot, expiry, attempt limits, action binding, optional hostname/session/IP binding, and a single-use response token. It is a low-friction mode; high-risk traffic should escalate to a visual challenge or an external provider.
## Regenerate and test
```bash
bun run scripts/generate-ui-component-reference.mjs
bun run typecheck
bun test packages/captcha
bun run --cwd examples/captcha-showcase check
bun run --cwd examples/captcha-showcase dev
```
Hard-refresh the browser after restarting the development server.
-9
View File
@@ -1,9 +0,0 @@
tsconfig.json
packages/styles/src/config.ts
packages/dev-server/package.json
packages/dev-server/src/hmr.ts
packages/dev-server/src/watch.ts
packages/dev-server/src/runtime.ts
packages/dev-server/src/index.ts
packages/dev-server/src/serve-entry.ts
packages/dev-toolbar/src/client/runtime.ts
+37
View File
@@ -0,0 +1,37 @@
# WRNexusJS 0.4.0
## Package platform
- Added automatic package plugin discovery from application dependencies and workspaces.
- Added package-owned components, client runtimes, assets, styles, routes, middleware, database migrations, and DevToolbar panels.
- Added duplicate/collision validation and strict discovery in development, build, doctor, and inspect flows.
- Added production content-hashed client runtimes, content-addressed package asset storage with stable public URLs, and page-scoped runtime injection.
- Added development bundling for TypeScript package runtimes and package-source HMR.
- Added CSR package runtime mount/unmount lifecycle.
## CLI and diagnostics
- Added `wrnexus inspect packages|plugins|routes|assets|runtimes|styles|migrations|bundle`.
- Added `wrnexus generate system <name>`.
- Expanded production build reports with all route types, plugins, runtimes, assets, component directories, and package migrations.
- Added the 0.4 application migration and release verification scripts.
- Fixed duplicate historical migration IDs.
## CAPTCHA
- Removed manual `captcha.js` copying and script inclusion.
- Removed duplicated CAPTCHA components from `@wrnexus/ui` and the showcase application.
- CAPTCHA is now discovered from `@wrnexus/captcha` and injected only on pages that render it.
- Preserved all challenge modes, audio, protected-page flow, validated form, compact/normal/big modes, Listen controls, not-robot mode, disturbance controls, and image-style renderers.
## Existing packages
Every existing package received an exported advanced capability layer. See `docs/PACKAGE-UPGRADES-0.4.md` for the package-by-package inventory.
## Fixes
- Package runtime and asset path collisions now fail before serving.
- Production style processing fails closed by default.
- Page ETags now include structured script metadata.
- Package `.wrn` changes invalidate Tailwind output during HMR.
- Package migrations use the same ordered migration contract as application migrations.
+109
View File
@@ -0,0 +1,109 @@
# WRNexusJS 0.4.0 upgrade manifest
This replacement upgrades the uploaded WRNexusJS repository to the 0.4.0 platform baseline and preserves existing APIs where practical.
Compared with the uploaded baseline archive, the final tree contains 45 added files, 214 modified files, and 15 removed obsolete or generated files.
## Main platform additions
- Automatic installed/workspace package discovery through `package.json` `wrnexus.plugin`
- Package-owned component directories
- Page-scoped client runtimes
- Virtual development assets with secure content types
- Hashed production runtime assets
- Package CSS entries and Tailwind scan sources
- Package page/API/realtime routes and middleware
- Package database migrations
- Package DevToolbar panels
- Dependency ordering, duplicate detection, cycle detection, and safe contribution validation
- Runtime mount/unmount lifecycle during CSR navigation
- Package-aware HMR and build reporting
- `wrnexus inspect` commands and `wrnexus generate system`
- 0.4 migration and compatibility tooling
## CAPTCHA integration
`@wrnexus/captcha` is now self-contained and automatically discovered. The old duplicate copies are removed from `@wrnexus/ui` and the showcase. The package contributes its own component, runtime, Tailwind scan source, and DevToolbar audit.
Developers use:
```bash
bun add @wrnexus/captcha
```
```wrn
<Captcha type="alphanumeric" action="contact-submit" />
```
They do not copy `captcha.js`, add a script tag, or manually register `captchaPlugin()` for normal use. The runtime is injected only on pages that render a CAPTCHA.
## Existing packages upgraded
| Package | 0.4.0 addition or hardening |
| ---------------------- | ------------------------------------------------------------------------------------ |
| `@wrnexus/syntax` | syntax contract, feature/version metadata, ranges and diagnostics |
| `@wrnexus/compiler` | compilation cache, dependency graph, cycle-safe cache implementation |
| `@wrnexus/reactive` | watchers, resources, abort/supersession handling and cleanup scopes |
| `@wrnexus/core` | service container, lifecycle, health, Problem Details, request IDs and idempotency |
| `@wrnexus/router` | named routes, manifests, reverse URLs and app-over-package precedence |
| `@wrnexus/ssr` | structured scripts, deduplication, CSP/SRI attributes and streaming output |
| `@wrnexus/csr` | lazy package runtime loading and navigation mount/unmount lifecycle |
| `@wrnexus/plugin` | discovery and package components/runtimes/assets/styles/routes/middleware/migrations |
| `@wrnexus/dev-server` | virtual assets, TS runtime bundling, package HMR and migration integration |
| `@wrnexus/cli` | package-aware builds, inspect commands, generator and 0.4 migration |
| `@wrnexus/dev-toolbar` | package panels and platform snapshots |
| `@wrnexus/styles` | package CSS/source support, package-only styles, token and contrast audits |
| `@wrnexus/test` | request/context utilities, JSON/problem assertions, cookies and deferred helpers |
| `@wrnexus/db` | migration ordering, cursor pagination, optimistic updates and tenant helpers |
| `@wrnexus/queue` | durable stores, claims, priority, retry, dead-letter and processing safety |
| `@wrnexus/pubsub` | resilient delivery and expiring defensive presence channels |
| `@wrnexus/uploader` | safe object keys, inspection, policy enforcement and signed file tokens |
| `@wrnexus/encryption` | versioned payloads and validated rotatable keyrings |
| `@wrnexus/jwt` | key-ID signing, validated keyrings and safe decoding |
| `@wrnexus/oauth` | state/PKCE storage, refresh, OIDC discovery and safe return URLs |
| `@wrnexus/authz` | explainable decisions, owner policies, composition and filtering |
| `@wrnexus/captcha` | automatic component/runtime, providers, audio, image styles, gates and guards |
| `@wrnexus/validation` | async/cross-field refinements and OpenAPI conversion |
| `@wrnexus/i18n` | fallbacks, coverage, formatters and plural messages |
| `@wrnexus/ui` | component metadata and reference audits |
| `@wrnexus/helpers` | retry, backoff, abortable sleep, timeouts and stable serialization |
| `@wrnexus/tracking` | sampled/batched telemetry and reliable sink handling |
| `@wrnexus/ai` | provider-neutral client, fallback providers and usage metadata |
| `@wrnexus/mobile` | deep links, offline tasks and environment detection |
| `@wrnexus/native` | capability manifests, permissions and target inspection |
## Upgrade and release tooling
- Root/package/service versions: 0.4.0
- VS Code extension version: 0.4.0
- Workspace lock versions updated
- `update.ts` includes a 0.4.0 migration
- Legacy CAPTCHA scripts are archived by migration rather than silently destroyed
- Obsolete generated/release artifacts removed
- New commands:
```bash
bun run verify:0.4
bun run validate:0.4
wrnexus doctor
wrnexus inspect packages .
wrnexus inspect plugins .
wrnexus inspect routes .
wrnexus inspect runtimes .
wrnexus inspect assets .
wrnexus generate system <name>
```
## Documentation included
- `README.md`
- `CHANGELOG-0.4.0.md`
- `docs/ARCHITECTURE-0.4.md`
- `docs/PACKAGE-RUNTIMES-0.4.md`
- `docs/PACKAGE-UPGRADES-0.4.md`
- `docs/UPGRADE-0.4.md`
- `docs/TEST-CHECKLIST-0.4.md`
- `VALIDATION-0.4.0.md`
- `MANIFEST-0.4.0.md`
See `VALIDATION-0.4.0.md` for the exact checks completed and the required final Bun validation commands.
-38
View File
@@ -1,38 +0,0 @@
# Bundle manifest
## Package
- Engine, challenge generators, optimized PNG renderer, audio renderer
- Memory, SQLite-compatible, and Redis-compatible stores
- Self-hosted, managed, Turnstile, reCAPTCHA, hCaptcha, and custom providers
- HTTP handlers, guards, page gates, adaptive policy, validation integration
- Plugin and DevToolbar audit panel
- Tailwind-only `Captcha.wrn` with compact/normal/big sizing, listen visibility, not-robot mode, 2575 disturbance control, and generated image renderer props
- English WAV audio assets
- Unit tests for all 18 PNG renderers, style resolution, security behavior, and documentation
## Example
- All self-hosted challenge types, including the not-robot checkbox
- Audio and invisible examples
- Shared `@wrnexus/validation` contact schema with client and API validation
- Protected form API using `captchaGuard`
- Working once-per-session protected-page grant and safe return redirect
- Compact, normal, and big component examples
- Listen-visible and listen-hidden examples
- Easy, normal, and hard image-disturbance examples
- Dedicated `/styles` gallery with 18 concrete renderers, random selection, allowed pools, and excluded pools
- External-provider placeholders
## Managed service
- Project keys, allowed hostnames, quotas, key rotation
- Public create/solve and secret-authenticated verification routes
- Memory reference store, SQL migration, OpenAPI specification
## Integration
- Repeat-safe installer
- TypeScript path aliases
- Current component-discovery compatibility copy
- Generated 18-style preview gallery and renderer guidance
+15 -33
View File
@@ -1,39 +1,21 @@
# WRNexusJS CAPTCHA complete bundle
# WRNexusJS 0.4.0 lint fix
This bundle targets WRNexusJS `main-80` / package version `0.3.6` and contains:
Extract this archive into the WRNexusJS repository root and replace the included files.
- `packages/captcha`: publishable `@wrnexus/captcha` package
- `packages/captcha/components/Captcha.wrn`: Tailwind-only UI component
- `examples/captcha-showcase`: runnable challenge, disturbance, validated-form, and protected-page showcase
- `services/managed-captcha`: managed API starter with projects, keys, hostname restrictions, quotas, and rotation
- `scripts/install-captcha.mjs`: repeat-safe repository installer
- `integration`: integration notes and generated path changes
- `docs/IMAGE-STYLES.md`: renderer catalog with a generated preview gallery
Fixes:
## Install into a WRNexusJS checkout
- Removes the `no-useless-assignment` failure in the CAPTCHA page-grant endpoint.
- Restores the Button showcase's specialized examples so `buttonUses` is used.
- Declares browser globals for the package-owned CAPTCHA runtime.
- Replaces empty catch blocks with documented best-effort cleanup.
- Fixes `prefer-const` in package discovery.
- Removes unused plugin, style-audit, and UI-test bindings.
- Uses the UI-reference verifier in the release workflow.
- Declares Node globals correctly in installer and validation scripts.
Run:
```bash
bun scripts/install-captcha.mjs /path/to/WRNexusJS \
--with-showcase \
--with-managed-service \
--check
bun run lint
bun run validate:0.4
```
The installer copies the package, places `Captcha.wrn` in `packages/ui/components` for current component discovery, adds TypeScript aliases, optionally installs the example and managed service, and can run validation commands.
Read `packages/captcha/README.md`, `packages/captcha/SECURITY.md`, and `services/managed-captcha/README.md` before production deployment.
## Fixed 9 compact-size update
- Compact width is now capped at `max-w-xs` instead of `max-w-sm`.
- Compact padding, gaps, image height, input height, buttons, icons, and text are smaller.
- The subtitle and provider-credit line are hidden in compact mode.
- Normal and big rendering remain unchanged.
## Fixed 8 integration updates
- Added 18 generated CAPTCHA image renderers plus `imageStyle="random"`.
- Added allowed and excluded renderer pools, forced randomization, and resolved style metadata.
- Added the `/styles` showcase page with every renderer and easy/normal/hard random examples.
- Optimized PNG line drawing and CRC generation for fast challenge creation.
- Retains compact/normal/big sizing, Listen controls, not-robot mode, page-gate, 2575 disturbance, validated form, audio, and runtime compatibility fixes.
-81
View File
@@ -1,81 +0,0 @@
# WRNexusJS DevToolbar Integration
Extract this ZIP directly over the WRNexusJS repository root and allow these files to be replaced.
## Replace files one by one
1. `tsconfig.json`
- Adds workspace path aliases for `@wrnexus/dev-toolbar` exports.
2. `packages/styles/src/config.ts`
- Adds `DevToolbarConfig` and `devToolbar` to `AppConfig`.
3. `packages/dev-server/package.json`
- Confirms the workspace dependency on `@wrnexus/dev-toolbar`.
4. `packages/dev-server/src/hmr.ts`
- Adds generic JSON broadcasting for toolbar events.
5. `packages/dev-server/src/watch.ts`
- Clears stale source issues and triggers rescans for CSS changes.
6. `packages/dev-server/src/runtime.ts`
- Serves toolbar routes.
- Injects the toolbar only in development HTML.
- Collects route, component, layout, and request errors.
- Sends HMR/browser events used by the toolbar.
7. `packages/dev-server/src/index.ts`
- Resolves toolbar configuration.
- Creates the server issue collector.
- Connects collector updates to HMR.
- Passes the toolbar into runtime and watcher dependencies.
8. `packages/dev-server/src/serve-entry.ts`
- Passes `config.devToolbar` from `wrnexus.config.ts` into the server.
9. `packages/dev-toolbar/src/client/runtime.ts`
- Loads server-side issues.
- Receives live toolbar issue/scan messages.
- Rescans after HMR and client navigation.
## Configuration
The toolbar is enabled automatically in development. Optional configuration:
```ts
export default {
devToolbar: {
enabled: true,
position: "bottom-center",
defaultOpen: false,
scanOnNavigation: true,
scanOnHmr: true,
openEditor: true,
editor: "code",
},
};
```
Disable it for an app:
```ts
export default {
devToolbar: false,
};
```
## After replacement
Run from the repository root:
```bash
bun install
bun run format
bun run typecheck
bun run lint
bun test
bun run check
```
The toolbar is development-only and is not injected when the server mode is `production`.
-7
View File
@@ -1,7 +0,0 @@
# Reactive runtime compatibility correction
The original Captcha component attempted to execute full native JavaScript inside WRNexusJS behavior blocks. WRNexusJS 0.3.6 behavior blocks use a deliberately limited eval-free interpreter and do not execute arbitrary JavaScript syntax.
The corrected design keeps the `.wrn` component declarative and delegates network, audio, timers, provider SDKs, DOM updates, and form guards to a packaged same-origin browser runtime.
This removes the `Unexpected token '?'` mount failure and prevents the later failures that would have occurred for `async`, `await`, `try`, callback functions, constructors, and member assignments.
+145
View File
@@ -0,0 +1,145 @@
# WRNexusJS 0.4.0 validation report
Validation date: 2026-07-25
Baseline archive:
- File: `WRNexusJS-main-current.zip`
- SHA-256: `7860543f1b0d8bb227857bb01f70c6a3719d51215e24990cc0b3e45a18211c50`
## Result
All validation that can be performed in this build environment passed. The repository is structurally aligned to 0.4.0, TypeScript source is syntactically and semantically valid under the project configuration used for validation, automatic package discovery works, CAPTCHA is loaded automatically without a copied `captcha.js`, editor checks pass, generated component documentation succeeds, and targeted runtime smoke checks pass.
Bun is not installed in this environment and external package installation is unavailable. Therefore the Bun unit-test suite, ESLint, Prettier, and real Bun production builds were not executed here. Run the final commands in the last section on the target Windows/Bun environment before publishing.
## Passed checks
### Repository structure
Command:
```bash
node scripts/verify-0.4.mjs
```
Passed:
- 30 `@wrnexus/*` packages aligned to version 0.4.0
- Managed CAPTCHA service aligned to version 0.4.0
- 73 unique updater migrations
- Package/runtime/asset/migration discovery markers
- CAPTCHA has no copied UI component or public JavaScript requirement
- Bun workspace lock structure
- VS Code extension package and lock versions
- Required 0.4 architecture, migration, runtime, testing, and documentation files
### TypeScript
Passed:
- 346 TypeScript files transpiled with zero syntax diagnostics
- Strict semantic TypeScript check completed with zero diagnostics
- Compiler cache runtime-cycle smoke check passed
The semantic check used the repository `tsconfig.json` plus temporary environment-only type roots for Node/Bun declarations. The temporary config is not included in the delivered archives.
### WRN source
Passed:
- 211 `.wrn` files parsed successfully
- CAPTCHA component parses without a manual `<script>` tag
- Runtime marker `data-wrnexus-runtime="captcha"` is present
### VS Code extension
Passed:
- 23 Node test cases
- Grammar, snippets, package metadata, language association, formatter, compiler, JSON, and Marketplace asset validation
### Generated UI documentation
Passed:
- 85 component detail pages generated
- 325 live demos generated
- 8 category pages generated
### JavaScript and JSON
Passed:
- 58 strict JSON files parsed
- 28 JavaScript/MJS/CJS files passed `node --check`
### Runtime smoke checks
Passed targeted Node/TypeScript smoke checks for:
- Automatic discovery of `@wrnexus/captcha` from the application dependency list
- Automatic CAPTCHA component, runtime, and style contributions
- Runtime marker collection and one-time script injection
- Correct JavaScript MIME type and `X-Content-Type-Options: nosniff`
- Package migration resolution
- Package style ordering
- Package-only CSS wrapper generation without an application stylesheet
- Application route precedence over package routes
- Durable queue visibility during processing, retries, and dead letters
- Telemetry batch requeue behavior with multiple failing sinks
- Syntax version and source ranges
- Service container, health registry, and idempotency store
- Reactive watcher lifecycle, resource supersession, and abort behavior
- Stable JSON with repeated references and circular-reference rejection
- Retry and backoff helpers
- PubSub validation and defensive presence values
- Upload content sniffing, safe object keys, and signed file tokens
- Encryption and JWT keyring duplicate/active-key validation and defensive copies
- Theme token and contrast audits
## Important architecture verification
The CAPTCHA package now behaves as a framework-native package:
1. An application installs `@wrnexus/captcha`.
2. WRNexusJS discovers `wrnexus.plugin` from the package manifest.
3. `<Captcha />` is resolved from the package component directory.
4. SSR records the `captcha` runtime marker only when the component is rendered.
5. Development serves `/__wrnexus/assets/captcha.js` with the correct MIME type.
6. Production emits a hashed runtime asset.
7. SSR or CSR navigation loads the runtime once and runs its mount lifecycle.
8. No user-authored script tag or copied public JavaScript file is required.
## Required final local validation
Run from the repository root with Bun 1.3.14 or newer:
```bash
bun install
bun run verify:0.4
bun run validate:0.4
```
`validate:0.4` runs:
```text
structural verification
full typecheck
ESLint
all package tests
Prettier check
integration tests
basic application tests and production build
component showcase check
CAPTCHA showcase check
managed CAPTCHA service tests
```
For release preparation, also run:
```bash
bun run release:prepare
```
Do not publish if any local Bun, lint, format, test, build, or migration check fails. The included code fixes the issues found through available static and runtime validation, but no responsible validation process can guarantee that future environments or untested integrations contain zero defects.
-27
View File
@@ -1,27 +0,0 @@
# Validation report
Validated in the artifact environment on 2026-07-25:
- Strict TypeScript checking passed for package source, managed-service source, and all included test files using local declarations for WRNexusJS/Bun APIs.
- Self-hosted engine smoke tests passed: incorrect answer, correct answer, challenge consumption, one-use response token, replay rejection, and expiry.
- Default generator smoke tests passed for number, alphabet, alphanumeric, calculation, generated-image selection, not-robot, invisible timing, PNG output, and bundled WAV rendering.
- Managed-service smoke tests passed for project creation, hostname-restricted challenge creation, solving, secret-authenticated verification, and response-token consumption.
- The installer passed first-install and repeated-install smoke tests, including TypeScript aliases, UI component placement, showcase installation, managed-service installation, and workspace updates.
- All showcase `.wrn` pages parsed with the available WRNexusJS parser.
- The `Captcha.wrn` body parsed after removing only the newer `@event` prop declarations; those declarations follow the same current syntax used by the frameworks InputNumber component.
- Dynamic attribute expressions were checked for the frameworks required single-quoted form.
- All used `--wire-*` variables were checked against the framework token set available in the repository snapshot.
Bun is not installed in this artifact environment, so the included Bun unit suite and full current-branch monorepo build were not executed here. Run the installer with `--check` in the current WRNexusJS checkout to execute component-reference generation, Bun tests, TypeScript checking, and the showcase build.
## Fixed 8 checks
- Strict TypeScript checking passed after adding renderer types, style resolution, generated image effects, component props, runtime payloads, managed API fields, and showcase tests.
- Every concrete renderer generated a valid PNG signature and a distinct image in deterministic smoke validation.
- The renderer catalog contains 18 concrete styles plus `random`; allowed and excluded pools reject unknown or empty configurations.
- Floating-point line endpoints now terminate correctly, including spiderweb, darts, wave, distortion, and scribble effects.
- CRC32 encoding uses a lookup table; all 18 preview images generated in under one second in the artifact environment.
- The component and browser runtime expose `imageStyle`, `allowedStyles`, `excludedStyles`, and `randomizeStyle`.
- The `/styles` showcase page includes easy, normal, hard, controlled-pool, and every concrete renderer example.
- Compact/normal/big sizing, Listen controls, not-robot, protected-page, 2575 disturbance, validated-form, audio, and static-asset fixes remain included.
-195
View File
@@ -1,195 +0,0 @@
--- a/packages/compiler/src/codegen.ts
+++ b/packages/compiler/src/codegen.ts
@@ -859,7 +859,11 @@
out.push(`export async function __wrnexusLoad(ctx: any) {\n${serverLoads.map((entry) => entry.body).join("\n")}\n}`);
}
if (clientLoads.length > 0) {
- out.push(`export const __wrnexusClientLoad = ${JSON.stringify(clientLoads.map((entry) => entry.body))};`);
+ out.push(
+ `export async function __wrnexusClientLoad(ctx: any) {
+${clientLoads.map((entry) => entry.body).join("\n")}
+}`,
+ );
}
}
--- a/packages/csr/src/reactive-runtime.ts
+++ b/packages/csr/src/reactive-runtime.ts
@@ -480,18 +480,39 @@
// signal.subscribe dedupes, so re-subscribing each run is cheap and bounded.
var currentRenderer = null;
var pendingRenderers = new Set();
- var renderScheduled = false;
+ var batchDepth = 0;
+ var flushingRenderers = false;
+
+ function flushRenderers() {
+ if (flushingRenderers || batchDepth > 0) return;
+
+ flushingRenderers = true;
+
+ try {
+ while (pendingRenderers.size > 0) {
+ var queue = Array.from(pendingRenderers);
+ pendingRenderers.clear();
+ queue.forEach(function (run) { run(); });
+ }
+ } finally {
+ flushingRenderers = false;
+ }
+ }
function scheduleRenderer(renderer) {
pendingRenderers.add(renderer);
- if (renderScheduled) return;
- renderScheduled = true;
- queueMicrotask(function () {
- renderScheduled = false;
- var queue = Array.from(pendingRenderers);
- pendingRenderers.clear();
- queue.forEach(function (run) { run(); });
- });
+ flushRenderers();
+ }
+
+ function batchUpdates(callback) {
+ batchDepth++;
+
+ try {
+ return callback();
+ } finally {
+ batchDepth--;
+ flushRenderers();
+ }
}
function reactive(fn) {
@@ -723,68 +744,70 @@
source,
locals,
) {
- var statements =
- splitStatements(source);
+ return batchUpdates(function () {
+ var statements =
+ splitStatements(source);
- for (
- var statementIndex = 0;
- statementIndex <
- statements.length;
- statementIndex++
- ) {
- var result = runStatement(
- statements[statementIndex],
- function (expression) {
- return evalExpr(
- expression,
- locals,
- );
- },
- function (name) {
- if (
- locals &&
- Object.prototype
- .hasOwnProperty.call(
- locals,
- name,
- )
- ) {
- return locals[name];
- }
-
- return peekScope(name);
- },
- function (name, value) {
- if (
- locals &&
- Object.prototype
- .hasOwnProperty.call(
- locals,
- name,
- )
- ) {
- locals[name] = value;
- } else {
- writeScope(name, value);
- }
- },
- function (body) {
- return runStmt(
- body,
- locals,
- );
- },
- );
+ for (
+ var statementIndex = 0;
+ statementIndex <
+ statements.length;
+ statementIndex++
+ ) {
+ var result = runStatement(
+ statements[statementIndex],
+ function (expression) {
+ return evalExpr(
+ expression,
+ locals,
+ );
+ },
+ function (name) {
+ if (
+ locals &&
+ Object.prototype
+ .hasOwnProperty.call(
+ locals,
+ name,
+ )
+ ) {
+ return locals[name];
+ }
+
+ return peekScope(name);
+ },
+ function (name, value) {
+ if (
+ locals &&
+ Object.prototype
+ .hasOwnProperty.call(
+ locals,
+ name,
+ )
+ ) {
+ locals[name] = value;
+ } else {
+ writeScope(name, value);
+ }
+ },
+ function (body) {
+ return runStmt(
+ body,
+ locals,
+ );
+ },
+ );
- if (result.returned) {
- return result;
+ if (result.returned) {
+ return result;
+ }
}
- }
- return {
- returned: false,
- value: undefined,
- };
+ return {
+ returned: false,
+ value: undefined,
+ };
+ });
}
function installBehaviorFunctions(source) {
+33 -33
View File
@@ -42,8 +42,8 @@
"@wrnexus/validation": "workspace:*",
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.119",
"@iconify/tailwind4": "^1.2.3",
"@iconify-json/lucide": "^1.2.0",
"@iconify/tailwind4": "^1.0.0",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2",
@@ -66,15 +66,15 @@
},
"packages/ai": {
"name": "@wrnexus/ai",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/authz": {
"name": "@wrnexus/authz",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/captcha": {
"name": "@wrnexus/captcha",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
@@ -88,7 +88,7 @@
},
"packages/cli": {
"name": "@wrnexus/cli",
"version": "0.3.6",
"version": "0.4.0",
"bin": {
"wrnexus": "src/index.ts",
},
@@ -109,29 +109,29 @@
},
"packages/compiler": {
"name": "@wrnexus/compiler",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/syntax": "workspace:*",
},
},
"packages/core": {
"name": "@wrnexus/core",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/csr": {
"name": "@wrnexus/csr",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/db": {
"name": "@wrnexus/db",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*",
@@ -151,7 +151,7 @@
},
"packages/dev-toolbar": {
"name": "@wrnexus/dev-toolbar",
"version": "0.3.6",
"version": "0.4.0",
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.2",
@@ -159,63 +159,63 @@
},
"packages/encryption": {
"name": "@wrnexus/encryption",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/helpers": {
"name": "@wrnexus/helpers",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/i18n": {
"name": "@wrnexus/i18n",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/jwt": {
"name": "@wrnexus/jwt",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/mobile": {
"name": "@wrnexus/mobile",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/native": "workspace:*",
},
},
"packages/native": {
"name": "@wrnexus/native",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/oauth": {
"name": "@wrnexus/oauth",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/plugin": {
"name": "@wrnexus/plugin",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/syntax": "workspace:*",
},
},
"packages/pubsub": {
"name": "@wrnexus/pubsub",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/queue": {
"name": "@wrnexus/queue",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/reactive": {
"name": "@wrnexus/reactive",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/router": {
"name": "@wrnexus/router",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*",
@@ -223,14 +223,14 @@
},
"packages/ssr": {
"name": "@wrnexus/ssr",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/styles": {
"name": "@wrnexus/styles",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
@@ -239,37 +239,37 @@
},
"packages/syntax": {
"name": "@wrnexus/syntax",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/test": {
"name": "@wrnexus/test",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/tracking": {
"name": "@wrnexus/tracking",
"version": "0.3.6",
"version": "0.4.0",
},
"packages/ui": {
"name": "@wrnexus/ui",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/uploader": {
"name": "@wrnexus/uploader",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/validation": {
"name": "@wrnexus/validation",
"version": "0.3.6",
"version": "0.4.0",
},
"services/managed-captcha": {
"name": "@wrnexus/managed-captcha-service",
"version": "0.3.6",
"version": "0.4.0",
"dependencies": {
"@wrnexus/captcha": "workspace:*",
},
+68
View File
@@ -0,0 +1,68 @@
# WRNexusJS 0.4 architecture
## Goals
WRNexusJS 0.4 keeps the existing SSR-first model while making advanced systems installable without application-owned copies or manual wiring. The framework remains conservative: HTML is rendered on the server, the reactive runtime loads only for reactive pages, and package browser code loads only when its component or markup declares a runtime requirement.
## Request and render path
1. The CLI or development server loads `wrnexus.config.*`.
2. `@wrnexus/plugin` discovers explicit plugins and installed package manifests.
3. Contributions are normalized and validated for duplicate IDs, paths, routes, and migrations.
4. The router combines application and package components/routes/middleware.
5. `.wrn` sources compile through plugin AST/code transforms.
6. SSR renders page and component HTML.
7. Rendered `data-wrnexus-runtime` markers are matched against registered client runtimes.
8. Only referenced runtime scripts are added to the response.
9. CSR navigation loads newly required runtime chunks, calls `mount`, and calls `unmount` before replacing old page content.
## Package contribution model
A package can contribute:
- component directories
- client runtimes
- static or generated assets
- stylesheet entries and Tailwind scan sources
- page, API, and realtime routes
- middleware
- database migrations
- DevToolbar panels
- compiler diagnostics and transforms
- build and server lifecycle hooks
Contributions are declared by a package plugin or by the `wrnexus` field in `package.json`.
## Client runtime rules
A runtime has a stable ID, source entry, loading policy, module/classic format, and singleton policy. Development serves it from `/__wrnexus/assets/`; TypeScript runtime entries are browser-bundled on demand. Production builds emit content-hashed immutable runtime chunks and store their paths in the static server manifest.
Runtime code should register:
```js
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
window.__wrnexusRuntimes.example = {
mount(root) {},
unmount(root) {},
};
```
Mount and unmount must be idempotent. Event listeners, observers, audio, timers, and provider widgets must be cleaned up during unmount.
## Package assets and styles
Package assets use validated framework paths and receive correct MIME and `nosniff` headers. Production stores package assets in content-addressed files while preserving their declared public URLs; immutable caching remains opt-in. Package component directories and style sources are automatically added to application stylesheet processing, so apps do not need manual Tailwind `@source` entries for installed systems.
Production stylesheet processing fails closed by default. A Tailwind/PostCSS failure cannot silently ship unprocessed CSS unless the application explicitly selects fallback behavior.
## Package migrations
Packages can register inline SQL, a SQL file, or an ordered directory. Migration names are namespaced by package migration ID and may target the default or a named database. Development applies application and package migrations through the same migration table. Production copies both into the build output.
## Development experience
The development server watches application and workspace package component/runtime/style sources. Package `.wrn` changes invalidate both compiled modules and the stylesheet cache. `wrnexus inspect` exposes packages, plugins, routes, assets, runtimes, styles, migrations, and build reports. DevToolbar receives a platform snapshot and package panels.
## Compatibility
The 0.4 migration removes legacy CAPTCHA script tags, archives manually copied runtime files instead of deleting them, and leaves user-authored application code intact. Existing explicit plugins continue to work and take precedence over automatically discovered plugins with the same name.
+20 -20
View File
@@ -6,26 +6,26 @@ The self-hosted and managed engines support 18 concrete renderers plus `random`.
## Concrete styles
| Style | Main effect | Suggested use |
| --- | --- | --- |
| `classic` | Dots, crossing lines, glyph jitter | General default pool |
| `collision` | Overlapping coloured glyphs and bars | Medium or hard challenges |
| `snow` | Dense snow-like speckles | Medium challenges |
| `corrosion` | Eroded glyph patches and rust-like noise | Medium or hard challenges |
| `spiderweb` | Connected web lines and nodes | Medium challenges |
| `cross-shadow` | Multi-colour offset shadows | Medium challenges |
| `split` | Horizontally shifted image bands | Medium or hard challenges |
| `split2` | Vertically shifted image strips | Medium or hard challenges |
| `cut` | Slashed and interrupted character strokes | Medium challenges |
| `darts` | Radial lines and target rings | Medium challenges |
| `distortion` | Two-axis sinusoidal distortion | Medium or hard challenges |
| `stitch` | Dashed seams and cross stitches | Easy or medium challenges |
| `striped` | Diagonal and horizontal stripes | Easy or medium challenges |
| `wave` | Strong wave transformation | Medium or hard challenges |
| `grid-noise` | Grid lines and translucent cells | Easy or medium challenges |
| `scribble` | Random-walk line scribbles | Medium challenges |
| `pixel` | Pixel blocks and shifted regions | Medium challenges |
| `broken-lines` | Missing stroke segments and fragments | Medium or hard challenges |
| Style | Main effect | Suggested use |
| -------------- | ----------------------------------------- | ------------------------- |
| `classic` | Dots, crossing lines, glyph jitter | General default pool |
| `collision` | Overlapping coloured glyphs and bars | Medium or hard challenges |
| `snow` | Dense snow-like speckles | Medium challenges |
| `corrosion` | Eroded glyph patches and rust-like noise | Medium or hard challenges |
| `spiderweb` | Connected web lines and nodes | Medium challenges |
| `cross-shadow` | Multi-colour offset shadows | Medium challenges |
| `split` | Horizontally shifted image bands | Medium or hard challenges |
| `split2` | Vertically shifted image strips | Medium or hard challenges |
| `cut` | Slashed and interrupted character strokes | Medium challenges |
| `darts` | Radial lines and target rings | Medium challenges |
| `distortion` | Two-axis sinusoidal distortion | Medium or hard challenges |
| `stitch` | Dashed seams and cross stitches | Easy or medium challenges |
| `striped` | Diagonal and horizontal stripes | Easy or medium challenges |
| `wave` | Strong wave transformation | Medium or hard challenges |
| `grid-noise` | Grid lines and translucent cells | Easy or medium challenges |
| `scribble` | Random-walk line scribbles | Medium challenges |
| `pixel` | Pixel blocks and shifted regions | Medium challenges |
| `broken-lines` | Missing stroke segments and fragments | Medium or hard challenges |
## Random pool
+94
View File
@@ -0,0 +1,94 @@
# Package runtimes and automatic assets
## User experience
Installing an advanced package should be enough:
```bash
bun add @wrnexus/captcha
```
Then use its component:
```wrn
<Captcha type="number" action="signup" />
```
The user does not copy JavaScript, add a public asset, add a script tag, or duplicate the component.
## Declaring a package plugin
```json
{
"name": "@example/maps",
"wrnexus": {
"plugin": {
"plugin": "./src/plugin.ts",
"export": "default",
"factory": true
}
}
}
```
```ts
import { definePlugin } from "@wrnexus/plugin";
export default function mapsPlugin() {
return definePlugin({
name: "@example/maps",
componentDirs: [new URL("../components", import.meta.url).pathname],
clientRuntimes: [
{
id: "maps",
entry: new URL("../client/maps.ts", import.meta.url).pathname,
type: "module",
load: "defer",
singleton: true,
},
],
styleSources: [
{
id: "maps-components",
source: new URL("../components", import.meta.url).pathname,
},
],
});
}
```
The rendered component declares:
```html
<div data-wrnexus-runtime="maps"></div>
```
## Development behavior
- Runtime URL defaults to `/__wrnexus/assets/maps.js`.
- JavaScript files can be served directly.
- TypeScript/TSX entries are bundled for the browser.
- MIME type is JavaScript and `X-Content-Type-Options: nosniff` is set.
- Assets use `no-cache` during development.
- Workspace package sources participate in HMR.
## Production behavior
- Runtime entries are browser-bundled and minified when required.
- The emitted filename includes a SHA-256 content hash.
- The asset is served with an immutable cache policy.
- The final HTML includes the runtime only when its marker appears.
- Multiple instances inject one script.
- Build reports list plugins, runtime chunks, assets, component directories, routes, and migrations.
## CSR navigation
Before replacing `#app`, WRNexusJS calls package `unmount` hooks for runtimes used by the current content. It imports script attributes from the fetched document, loads missing chunks once, then calls `mount` for the new page.
## Security requirements
- Runtime and asset IDs are validated.
- Duplicate IDs and public paths fail startup/build.
- Local package asset paths cannot contain traversal segments.
- Production builds do not rely on application public-directory copies.
- Secrets must remain server-side; browser runtimes receive only public configuration and one-use response tokens.
+38
View File
@@ -0,0 +1,38 @@
# Existing package upgrades in 0.4
All 30 packages are aligned to version 0.4.0.
| Package | Added or strengthened capability |
| ---------------------- | ---------------------------------------------------------------------------------------------- |
| `@wrnexus/syntax` | syntax version/features, source ranges, diagnostic summaries |
| `@wrnexus/compiler` | compilation cache and dependency graph |
| `@wrnexus/reactive` | watch, async resources, cleanup scopes |
| `@wrnexus/core` | service container, lifecycle, health checks, Problem Details, request IDs, idempotency |
| `@wrnexus/router` | named routes, manifests, reverse URL generation |
| `@wrnexus/ssr` | structured/deduplicated scripts and streaming document output |
| `@wrnexus/csr` | package runtime loading plus mount/unmount lifecycle during navigation |
| `@wrnexus/plugin` | automatic discovery and package components/runtimes/assets/styles/routes/middleware/migrations |
| `@wrnexus/dev-server` | virtual package assets, TypeScript runtime bundling, package HMR, migration integration |
| `@wrnexus/cli` | package-aware builds, inspect commands, system generator, 0.4 migration |
| `@wrnexus/dev-toolbar` | package panels and platform snapshots |
| `@wrnexus/styles` | package scan sources/entries, token and contrast audits, fail-closed production processing |
| `@wrnexus/test` | request/context helpers, JSON/problem assertions, deferred/wait utilities, cookie jar |
| `@wrnexus/db` | ordered migration lists, cursor pagination, optimistic updates, tenant/soft-delete helpers |
| `@wrnexus/queue` | pluggable durable store, leases, priorities, retries, dead letters |
| `@wrnexus/pubsub` | resilient delivery wrapper and expiring presence channels |
| `@wrnexus/uploader` | filename isolation, content inspection, checksums, policies, signed file tokens |
| `@wrnexus/encryption` | versioned encrypted payloads and rotatable keyrings |
| `@wrnexus/jwt` | key-ID signing, verification keyrings, safe decoding |
| `@wrnexus/oauth` | state/PKCE storage, refresh, OIDC discovery, safe return URLs |
| `@wrnexus/authz` | explainable authorization decisions, owner policies, compositions, filtering |
| `@wrnexus/captcha` | package-managed component/runtime, advanced challenges, page gates, audio, image styles |
| `@wrnexus/validation` | async cross-field refinements, async request parsing, OpenAPI conversion |
| `@wrnexus/i18n` | fallback chains, coverage reporting, locale formatters, plural message formatting |
| `@wrnexus/ui` | component metadata access and reference audits |
| `@wrnexus/helpers` | retry/backoff, abortable sleep, timeout, stable JSON, safe parsing |
| `@wrnexus/tracking` | sampled/batched telemetry pipeline and sink abstraction |
| `@wrnexus/ai` | provider-neutral client, fallback providers, usage metadata |
| `@wrnexus/mobile` | deep links, offline task queue, environment detection |
| `@wrnexus/native` | capability manifests, target inspection, permission manager |
These additions preserve existing public APIs. New advanced modules are exported from each package root.
+57
View File
@@ -0,0 +1,57 @@
# WRNexusJS 0.4 test checklist
## Automated
Run from the repository root:
```bash
bun install
bun run verify:0.4
bun run validate:0.4
```
`validate:0.4` performs:
1. structural release verification;
2. TypeScript checking;
3. ESLint;
4. all package tests;
5. Prettier verification;
6. 0.4 integration tests;
7. basic application tests and production build;
8. component showcase generation, tests, and build;
9. CAPTCHA showcase tests and build;
10. managed CAPTCHA service tests.
## Package runtime
- Package absent and marker absent: no runtime script.
- Package installed but component unused: no runtime script.
- One or many components used: one runtime script.
- Script has correct MIME and `nosniff` headers.
- TypeScript runtime entries compile in development.
- Production runtime filename is content hashed.
- CSR navigation to a runtime page loads and mounts it.
- Navigation away calls unmount and removes listeners/timers.
- Runtime and asset public-path collisions fail clearly.
## CAPTCHA
- No application public copy of `captcha.js` exists.
- No manual script tag exists.
- Number, alphabet, alphanumeric, calculation, audio, image, and not-robot modes work.
- Compact, normal, and big layouts work at mobile and desktop widths.
- Listen can be hidden or shown.
- Disturbance values 2575 affect image difficulty.
- All image styles and random pools render.
- Protected page grants and redirects after verification.
- Validated form rejects invalid fields and unverified CAPTCHA.
- Tokens expire, are single use, and are action/host bound.
## Migration
- Updating an old app creates a backup.
- Legacy CAPTCHA script tags are removed once.
- Legacy runtime files are archived, not silently lost.
- Re-running the migration is idempotent.
- Existing deployed apps continue to build.
+54
View File
@@ -0,0 +1,54 @@
# Upgrade to WRNexusJS 0.4.0
## Recommended upgrade
```bash
bun add -D @wrnexus/cli@0.4.0
bun x wrnexus update . --version=0.4.0
```
For this monorepo source bundle:
```bash
bun install
bun run verify:0.4
bun run validate:0.4
```
## CAPTCHA migration
Before 0.4, CAPTCHA integrations could contain copied files such as:
- `public/assets/wrnexus/captcha.js`
- `public/__wrnexus/captcha.js`
- a manually copied `Captcha.wrn`
- an application-authored `<script src="...captcha.js">`
The 0.4 updater:
1. removes known legacy CAPTCHA script tags from `.wrn` files;
2. archives public runtime copies under `.wrnexus/legacy-assets/0.4.0/`;
3. preserves customized files in the backup created before migration;
4. records the migration at `.wrnexus/migrations/0.4.0.json`.
After updating, keep `@wrnexus/captcha` in application dependencies and use `<Captcha />`. Its component and browser runtime are discovered automatically.
## Package authors
Add a `wrnexus.plugin` package manifest or manifest contributions. Ensure IDs are stable and globally unique. Client runtime code must expose idempotent `mount` and `unmount` functions. Use package migrations for schema required by the package instead of asking applications to copy migration files.
## Compatibility checks
Run:
```bash
wrnexus doctor .
wrnexus inspect packages .
wrnexus inspect plugins .
wrnexus inspect runtimes .
wrnexus inspect assets .
wrnexus inspect routes .
wrnexus inspect migrations .
```
Production style processing now fails the build when the configured processor fails. Install the applications Tailwind/PostCSS dependencies before building.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wrnexus",
"version": "0.3.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wrnexus",
"version": "0.3.0",
"version": "0.4.0",
"license": "SEE LICENSE IN LICENSE",
"devDependencies": {
"@vscode/vsce": "^3.9.2"
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "wrnexus",
"displayName": "WRNexus Language Support",
"description": "Complete language support for WRNexus .wrn files, including highlighting, formatting, diagnostics, snippets, lifecycle hooks, state watchers, component functions, completions, and definition navigation.",
"version": "0.3.4",
"version": "0.4.0",
"publisher": "wrnexus",
"private": true,
"license": "SEE LICENSE IN LICENSE",
+4 -4
View File
@@ -3,7 +3,7 @@ import type { Db, ExecResult } from "@wrnexus/db";
import { users } from "./schema.ts";
export async function GetUserByEmail(db: Db, args: { email: string }): Promise<{ id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null> {
return (await db.one("SELECT * FROM users WHERE email = ?", [args.email], users)) as { id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null;
return (await db.one("SELECT * FROM users WHERE email = $1", [args.email], users)) as { id: number; email: string; name: string; active: boolean; passwordHash: string; createdAt: Date } | null;
}
export async function ListUsers(db: Db): Promise<{ id: number; name: string; active: boolean }[]> {
@@ -11,13 +11,13 @@ export async function ListUsers(db: Db): Promise<{ id: number; name: string; act
}
export async function CountActive(db: Db, args: { active: boolean }): Promise<{ n: number } | null> {
return (await db.one("SELECT COUNT(*) AS n FROM users WHERE active = ?", [args.active])) as { n: number } | null;
return (await db.one("SELECT COUNT(*) AS n FROM users WHERE active = $1", [args.active])) as { n: number } | null;
}
export async function CreateUser(db: Db, args: { email: string; name: string; active: boolean }): Promise<ExecResult> {
return db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [args.email, args.name, args.active]);
return db.exec("INSERT INTO users (email, name, active) VALUES ($1, $2, $3)", [args.email, args.name, args.active]);
}
export async function DeactivateUser(db: Db, args: { id: number }): Promise<ExecResult> {
return db.exec("UPDATE users SET active = 0 WHERE id = ?", [args.id]);
return db.exec("UPDATE users SET active = 0 WHERE id = $1", [args.id]);
}
@@ -17,7 +17,7 @@ function safeReturnPath(value: string | null, baseUrl: URL): string {
export async function POST(ctx: Context): Promise<Response> {
const contentType = ctx.req.headers.get("content-type") ?? "";
let submittedReturnTo: string | null = null;
let submittedReturnTo: string | null;
if (contentType.includes("application/json")) {
const body = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
@@ -1,320 +0,0 @@
component Captcha {
props {
provider = "self-hosted"
siteKey = ""
type = "alphanumeric"
action = "form-submit"
presentation = "visual"
difficulty = "normal"
disturbance = 50
imageStyle = "random"
allowedStyles = ""
excludedStyles = ""
randomizeStyle = false
locale = "en"
size = "normal"
color = "primary"
class = ""
id = ""
name = "wrn-captcha-response"
endpoint = "/__wrnexus/captcha/challenge"
verifyEndpoint = "/__wrnexus/captcha/verify"
responseField = "wrn-captcha-response"
label = "Security verification"
description = "Complete the challenge to continue."
helpText = ""
requiredMessage = "Please complete the security check."
incorrectMessage = "That answer was not correct. Try again."
expiredMessage = "This challenge expired. Load a new one."
networkMessage = "The verification service is unavailable. Try again."
autoLoad = true
autoVerify = false
showVerify = true
showRefresh = true
showAudio = true
showListen = true
showStatus = true
disabled = false
required = true
compact = false
@event ready = function
@event challenge = function
@event input = function
@event verify = function
@event success = function
@event failure = function
@event expired = function
@event refresh = function
@event audioStart = function
@event audioEnd = function
@event error = function
}
view {
<section
{...attrs}
id='{id}'
data-wrn-captcha
data-captcha-provider='{provider}'
data-captcha-site-key='{siteKey}'
data-captcha-type='{type}'
data-captcha-action='{action}'
data-captcha-presentation='{presentation}'
data-captcha-difficulty='{difficulty}'
data-captcha-disturbance='{disturbance}'
data-captcha-image-style='{imageStyle}'
data-captcha-allowed-styles='{allowedStyles}'
data-captcha-excluded-styles='{excludedStyles}'
data-captcha-randomize-style='{randomizeStyle}'
data-captcha-locale='{locale}'
data-captcha-size='{size}'
data-captcha-color='{color}'
data-captcha-endpoint='{endpoint}'
data-captcha-verify-endpoint='{verifyEndpoint}'
data-captcha-response-field='{responseField}'
data-captcha-name='{name}'
data-captcha-auto-load='{autoLoad}'
data-captcha-auto-verify='{autoVerify}'
data-captcha-show-verify='{showVerify}'
data-captcha-show-refresh='{showRefresh}'
data-captcha-show-audio='{showAudio}'
data-captcha-show-listen='{showListen}'
data-captcha-show-status='{showStatus}'
data-captcha-disabled='{disabled}'
data-captcha-required='{required}'
data-captcha-compact='{compact}'
data-captcha-required-message='{requiredMessage}'
data-captcha-incorrect-message='{incorrectMessage}'
data-captcha-expired-message='{expiredMessage}'
data-captcha-network-message='{networkMessage}'
data-captcha-status="idle"
data-server-verification-required="true"
aria-busy="false"
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
>
<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'>
<span
aria-hidden="true"
class='icon-[lucide--shield-check] size-5 shrink-0 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-4'
></span>
<h3 class='m-0 text-sm font-semibold leading-5 text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
{label}
</h3>
</div>
<p class='m-0 mt-1 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-size=big]/captcha:text-sm'>
{description}
</p>
</div>
<span
data-captcha-verified-badge
hidden
class='shrink-0 items-center gap-1 rounded-full bg-[color-mix(in_srgb,var(--wire-color-success)_12%,transparent)] px-2 py-1 text-xs font-semibold text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-0.5 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:py-0.5 group-data-[captcha-size=compact]/captcha:text-[10px]'
>
<span aria-hidden="true" class='icon-[lucide--circle-check] size-3.5 group-data-[captcha-size=compact]/captcha:size-3'></span>
Verified
</span>
</header>
<div
data-captcha-provider-mount
hidden
class='flex min-h-16 w-full items-center justify-center overflow-hidden rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-2 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=compact]/captcha:p-1.5'
></div>
<div
data-captcha-loading
hidden
class='flex min-h-28 items-center justify-center gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] text-sm text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:min-h-14 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:min-h-36 group-data-[captcha-type=not-robot]/captcha:m-3 group-data-[captcha-type=not-robot]/captcha:min-h-16'
>
<span aria-hidden="true" class='icon-[lucide--loader-circle] size-5 animate-spin group-data-[captcha-size=compact]/captcha:size-4'></span>
Loading challenge…
</div>
<div data-captcha-challenge hidden class='flex flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
<div data-captcha-prompt-row class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-2'>
<p data-captcha-prompt class='m-0 text-sm font-medium text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4'></p>
<span data-captcha-countdown hidden class='shrink-0 text-xs tabular-nums text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px]'></span>
</div>
<div data-captcha-not-robot hidden class='w-full'>
<button
data-captcha-not-robot-button
type="button"
aria-pressed="false"
class='flex min-h-20 w-full items-center gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-4 py-3 text-left transition-[border-color,box-shadow,background-color] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-wait disabled:opacity-80 group-data-[captcha-size=compact]/captcha:min-h-16 group-data-[captcha-size=compact]/captcha:gap-2.5 group-data-[captcha-size=compact]/captcha:px-3 group-data-[captcha-size=compact]/captcha:py-2.5 group-data-[captcha-size=big]/captcha:min-h-24 group-data-[captcha-size=big]/captcha:px-5 group-data-[captcha-size=big]/captcha:py-4'
>
<span
data-captcha-not-robot-control
aria-hidden="true"
class='inline-flex size-7 shrink-0 items-center justify-center rounded-md border-2 border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] text-white transition-colors data-[verified=true]:border-[var(--wire-color-success)] data-[verified=true]:bg-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:size-6 group-data-[captcha-size=big]/captcha:size-8'
>
<span data-captcha-not-robot-empty class='size-full'></span>
<span data-captcha-not-robot-spinner hidden class='icon-[lucide--loader-circle] size-4 animate-spin text-[var(--captcha-accent)]'></span>
<span data-captcha-not-robot-check hidden class='icon-[lucide--check] size-5'></span>
</span>
<span data-captcha-not-robot-label class='min-w-0 flex-1 text-sm font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
I'm not a robot
</span>
<span class='flex shrink-0 flex-col items-center gap-0.5 text-[10px] leading-none text-[var(--wire-color-muted)]'>
<span aria-hidden="true" class='icon-[lucide--shield-check] size-6 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-5 group-data-[captcha-size=big]/captcha:size-7'></span>
<span>WRNexus</span>
</span>
</button>
</div>
<div
data-captcha-image-wrap
hidden
class='overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white'
>
<img
data-captcha-image
src=""
alt="CAPTCHA challenge"
draggable="false"
class='block h-auto min-h-20 w-full select-none object-contain group-data-[captcha-size=compact]/captcha:max-h-24 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=big]/captcha:min-h-28'
/>
</div>
<div
data-captcha-items
hidden
role="group"
class='grid grid-cols-2 gap-2 group-data-[captcha-size=compact]/captcha:gap-1 sm:grid-cols-3 group-data-[captcha-size=big]/captcha:gap-3'
></div>
<template data-captcha-item-template>
<button
type="button"
aria-pressed="false"
class='group relative overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white p-1 group-data-[captcha-size=compact]/captcha:p-0.5 transition-[border-color,box-shadow,transform] duration-[var(--wire-motion-fast)] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] aria-[pressed=true]:border-[var(--captcha-accent)] aria-[pressed=true]:ring-2 aria-[pressed=true]:ring-[var(--captcha-accent)]'
>
<img
data-captcha-item-image
src=""
alt=""
draggable="false"
class='aspect-[4/3] w-full select-none object-cover'
/>
<span
data-captcha-item-check
hidden
aria-hidden="true"
class='absolute right-1.5 top-1.5 inline-flex size-5 group-data-[captcha-size=compact]/captcha:right-1 group-data-[captcha-size=compact]/captcha:top-1 group-data-[captcha-size=compact]/captcha:size-4 items-center justify-center rounded-full bg-[var(--captcha-accent)] text-white shadow-sm'
>
<span class='icon-[lucide--check] size-3.5'></span>
</span>
</button>
</template>
<label data-captcha-answer-wrap hidden class='flex flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
<input
data-captcha-answer
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
placeholder="Enter the answer"
/>
</label>
<input
data-captcha-honeypot
type="text"
name=""
value=""
tabindex="-1"
autocomplete="off"
aria-hidden="true"
class='pointer-events-none absolute -left-[10000px] top-auto h-px w-px overflow-hidden opacity-0'
/>
</div>
<div data-captcha-success hidden role="status" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
<span aria-hidden="true" class='icon-[lucide--circle-check] mt-0.5 size-3.5 shrink-0'></span>
<span data-captcha-success-message>Verification completed.</span>
</div>
<div data-captcha-error hidden role="alert" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:mx-3 group-data-[captcha-type=not-robot]/captcha:mb-3'>
<span aria-hidden="true" class='icon-[lucide--circle-alert] mt-0.5 size-3.5 shrink-0'></span>
<span data-captcha-error-message></span>
</div>
<p data-captcha-help class='m-0 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
{helpText}
</p>
<footer data-captcha-footer class='flex flex-wrap items-center justify-between gap-2 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-type=not-robot]/captcha:hidden'>
<div class='flex flex-wrap items-center gap-1.5 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-0.5'>
<button
data-captcha-audio
hidden
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span aria-hidden="true" class='icon-[lucide--volume-2] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
Listen
</button>
<button
data-captcha-audio-alternative
hidden
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span aria-hidden="true" class='icon-[lucide--ear] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
Use audio
</button>
<button
data-captcha-refresh
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span aria-hidden="true" class='icon-[lucide--refresh-cw] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
New challenge
</button>
</div>
<button
data-captcha-verify
type="button"
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] bg-[var(--captcha-accent)] px-3 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-2.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-white shadow-sm transition-[filter,transform] hover:brightness-95 active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--wire-color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
>
<span data-captcha-verify-icon aria-hidden="true" class='icon-[lucide--shield-check] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
<span data-captcha-verify-spinner hidden aria-hidden="true" class='icon-[lucide--loader-circle] size-4 animate-spin group-data-[captcha-size=compact]/captcha:size-3.5'></span>
<span data-captcha-verify-label>Verify</span>
</button>
</footer>
<input
data-captcha-response
type="hidden"
name='{responseField}'
value=""
/>
<p data-captcha-credit class='m-0 text-[11px] leading-4 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-type=not-robot]/captcha:hidden'>
Protected by WRNexus CAPTCHA. Server-side verification is required.
</p>
</section>
}
}
+5 -1
View File
@@ -1,4 +1,8 @@
import { createCaptchaEngine, createCaptchaHttpHandlers, MemoryCaptchaStore } from "@wrnexus/captcha/server";
import {
createCaptchaEngine,
createCaptchaHttpHandlers,
MemoryCaptchaStore,
} from "@wrnexus/captcha/server";
export const captchaEngine = createCaptchaEngine({
secret: process.env.CAPTCHA_SECRET ?? "development-only-change-this-captcha-secret-123456",
@@ -1,7 +1,13 @@
@import "tailwindcss";
@plugin "@iconify/tailwind4";
@source "../**/*.wrn";
@source "../../../packages/captcha/components/*.wrn";
html { color-scheme: light dark; }
body { margin: 0; background: var(--wire-color-bg); color: var(--wire-color-text); font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif; }
html {
color-scheme: light dark;
}
body {
margin: 0;
background: var(--wire-color-bg);
color: var(--wire-color-text);
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
}
@@ -1,878 +0,0 @@
(function () {
"use strict";
var RUNTIME_KEY = "__wrnexusCaptchaRuntime";
var existingRuntime = window[RUNTIME_KEY];
if (existingRuntime && typeof existingRuntime.scan === "function") {
existingRuntime.scan(document);
return;
}
var states = new WeakMap();
var scriptPromises = new Map();
var instanceCounter = 0;
function bool(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
return value === true || value === "true" || value === "1";
}
function numberInRange(value, fallback, minimum, maximum) {
var parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(minimum, Math.min(maximum, Math.round(parsed)));
}
function normalizeSize(value, compact) {
if (compact) return "compact";
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";
}
function commaList(value) {
return String(value || "")
.split(",")
.map(function (item) { return item.trim().toLowerCase(); })
.filter(Boolean);
}
function text(root, selector, value) {
var element = root.querySelector(selector);
if (element) element.textContent = value == null ? "" : String(value);
}
function show(element, visible) {
if (!element) return;
element.hidden = !visible;
}
function setBusy(root, busy) {
root.setAttribute("aria-busy", busy ? "true" : "false");
}
function config(root) {
var data = root.dataset;
var responseField = data.captchaResponseField || data.captchaName || "wrn-captcha-response";
var compact = bool(data.captchaCompact, false);
var size = normalizeSize(data.captchaSize, compact);
return {
provider: data.captchaProvider || "self-hosted",
siteKey: data.captchaSiteKey || "",
type: data.captchaType || "alphanumeric",
action: data.captchaAction || "form-submit",
presentation: data.captchaPresentation || "visual",
difficulty: data.captchaDifficulty || "normal",
disturbance: numberInRange(data.captchaDisturbance, 50, 25, 75),
imageStyle: data.captchaImageStyle || "random",
allowedStyles: commaList(data.captchaAllowedStyles),
excludedStyles: commaList(data.captchaExcludedStyles),
randomizeStyle: bool(data.captchaRandomizeStyle, false),
locale: data.captchaLocale || "en",
size: size,
endpoint: data.captchaEndpoint || "/__wrnexus/captcha/challenge",
verifyEndpoint: data.captchaVerifyEndpoint || "/__wrnexus/captcha/verify",
responseField: responseField,
autoLoad: bool(data.captchaAutoLoad, true),
autoVerify: bool(data.captchaAutoVerify, false),
showVerify: bool(data.captchaShowVerify, true),
showRefresh: bool(data.captchaShowRefresh, true),
showAudio: bool(data.captchaShowAudio, true),
showListen: bool(data.captchaShowListen, true),
showStatus: bool(data.captchaShowStatus, true),
disabled: bool(data.captchaDisabled, false),
required: bool(data.captchaRequired, true),
compact: compact,
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.",
};
}
function eventDetail(state, extra) {
var challenge = state.challenge;
return {
component: "Captcha",
provider: state.config.provider,
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,
size: state.config.size,
status: state.status,
challengeId: challenge && challenge.id ? challenge.id : "",
responseToken: state.responseToken,
expiresAt: challenge && challenge.expiresAt ? challenge.expiresAt : null,
extra: extra || null,
};
}
function emit(state, name, extra) {
state.root.dispatchEvent(
new CustomEvent(name, {
bubbles: true,
detail: eventDetail(state, extra),
}),
);
}
function setResponseToken(state, token) {
state.responseToken = token || "";
var input = state.root.querySelector("[data-captcha-response]");
if (input) {
input.name = state.config.responseField;
input.value = state.responseToken;
}
}
function isNotRobot(state) {
return Boolean(
(state.challenge && state.challenge.type === "not-robot") ||
(!state.challenge && state.config.type === "not-robot"),
);
}
function updateNotRobotState(state) {
var panel = state.root.querySelector("[data-captcha-not-robot]");
var button = state.root.querySelector("[data-captcha-not-robot-button]");
var empty = state.root.querySelector("[data-captcha-not-robot-empty]");
var spinner = state.root.querySelector("[data-captcha-not-robot-spinner]");
var check = state.root.querySelector("[data-captcha-not-robot-check]");
var control = state.root.querySelector("[data-captcha-not-robot-control]");
var label = state.root.querySelector("[data-captcha-not-robot-label]");
var active = isNotRobot(state);
var pending = state.notRobotPending || state.status === "verifying";
var verified = state.status === "verified";
show(panel, active && state.status !== "loading" && state.status !== "idle");
if (!active) return;
if (button) {
button.disabled = state.config.disabled || pending || verified || state.status === "expired";
button.setAttribute("aria-pressed", verified ? "true" : "false");
}
show(empty, !pending && !verified);
show(spinner, pending && !verified);
show(check, verified);
if (control) control.dataset.verified = verified ? "true" : "false";
if (label) {
label.textContent = verified ? "Verified" : pending ? "Checking…" : "I'm not a robot";
}
}
function setStatus(state, status, message) {
state.status = status;
state.root.dataset.captchaStatus = status;
setBusy(state.root, status === "loading" || status === "verifying");
var loading = state.root.querySelector("[data-captcha-loading]");
var challenge = state.root.querySelector("[data-captcha-challenge]");
var success = state.root.querySelector("[data-captcha-success]");
var error = state.root.querySelector("[data-captcha-error]");
var badge = state.root.querySelector("[data-captcha-verified-badge]");
var verifyButton = state.root.querySelector("[data-captcha-verify]");
var verifyIcon = state.root.querySelector("[data-captcha-verify-icon]");
var verifySpinner = state.root.querySelector("[data-captcha-verify-spinner]");
var verifyLabel = state.root.querySelector("[data-captcha-verify-label]");
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(badge, status === "verified");
if (state.config.showStatus) {
show(success, status === "verified");
show(error, Boolean(message) && status !== "verified");
} else {
show(success, false);
show(error, false);
}
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.setAttribute("aria-invalid", status === "incorrect" ? "true" : "false");
}
if (verifyButton) {
verifyButton.disabled = state.config.disabled || status === "loading" || status === "verifying" || status === "verified" || status === "expired";
}
show(verifyIcon, status !== "verifying");
show(verifySpinner, status === "verifying");
if (verifyLabel) verifyLabel.textContent = status === "verifying" ? "Verifying…" : "Verify";
updateNotRobotState(state);
updateControls(state);
}
function clearTimer(state) {
if (state.timer) {
window.clearInterval(state.timer);
state.timer = null;
}
}
function updateCountdown(state) {
var countdown = state.root.querySelector("[data-captcha-countdown]");
if (!countdown || !state.challenge || !state.challenge.expiresAt) {
show(countdown, false);
return;
}
var remaining = Math.max(0, Math.ceil((Number(state.challenge.expiresAt) - Date.now()) / 1000));
countdown.textContent = remaining + "s";
show(countdown, remaining > 0 && state.status !== "verified");
if (remaining <= 0 && state.status !== "verified" && state.status !== "expired") {
clearTimer(state);
setResponseToken(state, "");
setStatus(state, "expired", state.config.expiredMessage);
emit(state, "expired");
}
}
function startTimer(state) {
clearTimer(state);
if (!state.challenge || !state.challenge.expiresAt) return;
updateCountdown(state);
state.timer = window.setInterval(function () {
updateCountdown(state);
}, 1000);
}
function resetUi(state) {
if (state.audioPlayer) {
state.audioPlayer.pause();
state.audioPlayer.removeAttribute("src");
state.audioPlayer.load();
state.audioPlayer = null;
}
if (state.notRobotTimer) {
window.clearTimeout(state.notRobotTimer);
state.notRobotTimer = null;
}
state.notRobotPending = false;
state.challengeLoadedAt = 0;
state.answer = "";
state.selections = [];
state.challenge = null;
setResponseToken(state, "");
var answer = state.root.querySelector("[data-captcha-answer]");
var items = state.root.querySelector("[data-captcha-items]");
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
var providerMount = state.root.querySelector("[data-captcha-provider-mount]");
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
var notRobot = state.root.querySelector("[data-captcha-not-robot]");
if (answer) answer.value = "";
if (items) items.replaceChildren();
show(items, false);
show(imageWrap, false);
show(notRobot, false);
if (honeypot) {
honeypot.value = "";
honeypot.name = "";
}
if (providerMount && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha") {
providerMount.replaceChildren();
show(providerMount, false);
}
}
function updateControls(state) {
var challenge = state.challenge;
var audio = state.root.querySelector("[data-captcha-audio]");
var audioAlternative = state.root.querySelector("[data-captcha-audio-alternative]");
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 notRobot = isNotRobot(state);
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(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";
}
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) {
show(container, false);
return;
}
container.replaceChildren();
container.setAttribute("aria-label", challenge.prompt || "Select matching images");
challenge.items.forEach(function (item, index) {
var fragment = template.content.cloneNode(true);
var button = fragment.querySelector("button");
var image = fragment.querySelector("[data-captcha-item-image]");
if (!button || !image) return;
button.dataset.captchaItemId = item.id;
button.setAttribute("aria-label", item.alt || "Challenge tile " + (index + 1));
image.src = item.image;
image.alt = "";
button.addEventListener("click", function () {
toggleItem(state, item.id, button);
});
container.appendChild(fragment);
});
show(container, true);
}
function toggleItem(state, itemId, button) {
if (state.config.disabled || state.status !== "ready") return;
var index = state.selections.indexOf(itemId);
var selected = index >= 0;
if (selected) {
state.selections.splice(index, 1);
} else {
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) + '"]');
if (previousButton) {
previousButton.setAttribute("aria-pressed", "false");
show(previousButton.querySelector("[data-captcha-item-check]"), false);
}
}
state.selections.push(itemId);
}
var nowSelected = state.selections.indexOf(itemId) >= 0;
button.setAttribute("aria-pressed", nowSelected ? "true" : "false");
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);
if (state.config.autoVerify && minimum > 0 && state.selections.length >= minimum) verify(state);
}
function renderChallenge(state, challenge) {
state.challenge = challenge;
state.challengeLoadedAt = Date.now();
state.notRobotPending = false;
state.answer = "";
state.selections = [];
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.");
var promptRow = state.root.querySelector("[data-captcha-prompt-row]");
var notRobotPanel = state.root.querySelector("[data-captcha-not-robot]");
show(promptRow, !notRobot);
show(notRobotPanel, notRobot);
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
var image = state.root.querySelector("[data-captcha-image]");
if (!notRobot && challenge.image && image) {
image.src = challenge.image;
show(imageWrap, true);
} else {
if (image) image.removeAttribute("src");
show(imageWrap, false);
}
if (notRobot) {
var items = state.root.querySelector("[data-captcha-items]");
if (items) items.replaceChildren();
show(items, false);
} else {
renderItems(state, challenge);
}
var answerWrap = state.root.querySelector("[data-captcha-answer-wrap]");
var answer = state.root.querySelector("[data-captcha-answer]");
var needsAnswer = !notRobot && !challenge.items && challenge.inputMode !== "none";
show(answerWrap, needsAnswer);
if (answer) {
answer.value = "";
answer.inputMode = challenge.inputMode === "numeric" ? "numeric" : "text";
answer.setAttribute("aria-invalid", "false");
}
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
if (honeypot) honeypot.name = challenge.honeypotField || "";
setStatus(state, "ready", "");
startTimer(state);
emit(state, "challenge", challenge);
emit(state, "ready");
if (state.config.autoVerify && !notRobot && challenge.inputMode === "none" && !challenge.items) {
window.setTimeout(function () {
verify(state);
}, 0);
}
}
async function createChallenge(state, requestedPresentation) {
if (state.config.disabled) return;
if (state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha") {
await mountExternal(state);
return;
}
clearTimer(state);
resetUi(state);
setStatus(state, "loading", "");
try {
var response = await fetch(state.config.endpoint, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
siteKey: state.config.siteKey,
action: state.config.action,
type: state.config.type,
presentation: requestedPresentation || state.config.presentation,
difficulty: state.config.difficulty,
disturbance: state.config.disturbance,
imageStyle: state.config.imageStyle,
allowedStyles: state.config.allowedStyles,
excludedStyles: state.config.excludedStyles,
randomizeStyle: state.config.randomizeStyle,
locale: state.config.locale,
responseField: state.config.responseField,
}),
});
var result = await response.json().catch(function () {
return null;
});
if (!response.ok || !result || !result.id) {
throw new Error(result && result.message ? result.message : "Challenge request failed");
}
renderChallenge(state, result);
} catch (error) {
setStatus(state, "network-error", state.config.networkMessage);
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
}
}
async function verify(state) {
state.notRobotPending = false;
if (state.config.disabled || state.status === "loading" || state.status === "verifying" || state.status === "expired" || state.status === "verified") {
updateNotRobotState(state);
updateControls(state);
return;
}
if (!state.challenge) {
await createChallenge(state);
return;
}
var answerInput = state.root.querySelector("[data-captcha-answer]");
var honeypotInput = state.root.querySelector("[data-captcha-honeypot]");
state.answer = answerInput ? answerInput.value : "";
setStatus(state, "verifying", "");
emit(state, "verify");
try {
var response = await fetch(state.challenge.verifyUrl || state.config.verifyEndpoint, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
challengeId: state.challenge.id,
action: state.config.action,
answer: state.answer,
selections: state.selections,
honeypot: honeypotInput ? honeypotInput.value : "",
timingToken: state.challenge.timingToken || "",
}),
});
var result = await response.json().catch(function () {
return null;
});
if (!response.ok || !result || !result.success) {
setResponseToken(state, "");
var expired = Boolean(result && result.code === "expired");
setStatus(
state,
expired ? "expired" : "incorrect",
expired
? state.config.expiredMessage
: result && result.message
? result.message
: state.config.incorrectMessage,
);
emit(state, "failure", result || null);
return;
}
clearTimer(state);
setResponseToken(state, result.responseToken || "");
setStatus(state, "verified", "Verification completed.");
emit(state, "success", result);
} catch (error) {
setStatus(state, "network-error", state.config.networkMessage);
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
}
}
function verifyNotRobot(state) {
if (state.config.disabled || state.notRobotPending || state.status !== "ready") return;
if (!state.challenge || state.challenge.type !== "not-robot") {
createChallenge(state);
return;
}
var configuredMinimum = Number(
state.challenge.metadata && state.challenge.metadata.minCompletionMs !== undefined
? state.challenge.metadata.minCompletionMs
: 800,
);
var minimum = Number.isFinite(configuredMinimum) ? Math.max(0, configuredMinimum) : 800;
var elapsed = Math.max(0, Date.now() - state.challengeLoadedAt);
var remaining = Math.max(0, minimum - elapsed);
state.notRobotPending = true;
updateNotRobotState(state);
updateControls(state);
emit(state, "input", { checked: true });
var complete = function () {
state.notRobotTimer = null;
state.notRobotPending = false;
verify(state);
};
if (remaining > 0) state.notRobotTimer = window.setTimeout(complete, remaining);
else complete();
}
function providerDefinition(provider) {
if (provider === "turnstile") {
return {
url: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
globalName: "turnstile",
};
}
if (provider === "recaptcha") {
return {
url: "https://www.google.com/recaptcha/api.js?render=explicit",
globalName: "grecaptcha",
};
}
if (provider === "hcaptcha") {
return {
url: "https://js.hcaptcha.com/1/api.js?render=explicit",
globalName: "hcaptcha",
};
}
return null;
}
function loadProviderScript(definition) {
if (window[definition.globalName]) return Promise.resolve(window[definition.globalName]);
if (scriptPromises.has(definition.url)) return scriptPromises.get(definition.url);
var promise = new Promise(function (resolve, reject) {
var script = document.querySelector('script[src="' + definition.url + '"]');
if (!script) {
script = document.createElement("script");
script.src = definition.url;
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
script.addEventListener(
"load",
function () {
if (window[definition.globalName]) resolve(window[definition.globalName]);
else reject(new Error("CAPTCHA provider did not initialize"));
},
{ once: true },
);
script.addEventListener("error", function () { reject(new Error("CAPTCHA provider script failed")); }, { once: true });
});
scriptPromises.set(definition.url, promise);
return promise;
}
async function mountExternal(state) {
var definition = providerDefinition(state.config.provider);
if (!definition) return;
if (!state.config.siteKey) {
setStatus(state, "provider-error", "A public site key is required.");
return;
}
var mount = state.root.querySelector("[data-captcha-provider-mount]");
if (!mount) return;
show(mount, true);
setStatus(state, "loading", "");
try {
var api = await loadProviderScript(definition);
if (state.externalWidgetId !== null && typeof api.reset === "function") {
setResponseToken(state, "");
api.reset(state.externalWidgetId);
setStatus(state, "ready", "");
emit(state, "ready");
return;
}
mount.replaceChildren();
state.externalApi = api;
state.externalWidgetId = api.render(mount, {
sitekey: state.config.siteKey,
theme: "auto",
size: state.config.size === "compact" ? "compact" : state.config.provider === "turnstile" ? "flexible" : "normal",
action: state.config.action,
callback: function (token) {
setResponseToken(state, token);
setStatus(state, "verified", "Verification completed.");
emit(state, "success");
},
"expired-callback": function () {
setResponseToken(state, "");
setStatus(state, "expired", state.config.expiredMessage);
emit(state, "expired");
},
"error-callback": function (providerError) {
setResponseToken(state, "");
setStatus(state, "provider-error", state.config.networkMessage);
emit(state, "error", { error: providerError || "provider-error" });
},
});
setStatus(state, "ready", "");
emit(state, "ready");
} catch (error) {
setStatus(state, "provider-error", state.config.networkMessage);
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
}
}
function playAudio(state) {
if (!state.challenge || !state.challenge.audioUrl || state.config.disabled) return;
if (state.audioPlayer) {
state.audioPlayer.pause();
state.audioPlayer.currentTime = 0;
}
var audio = new Audio();
state.audioPlayer = audio;
audio.preload = "auto";
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("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),
});
});
}
function onFormSubmit(state, event) {
if (!state.config.required || state.responseToken) return;
event.preventDefault();
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]");
if (target && typeof target.focus === "function") target.focus();
}
function initialize(root) {
if (!(root instanceof HTMLElement) || states.has(root)) return;
if (!root.id) {
instanceCounter += 1;
root.id = "wrn-captcha-" + instanceCounter;
}
var state = {
root: root,
config: config(root),
challenge: null,
answer: "",
selections: [],
responseToken: "",
status: "idle",
timer: null,
notRobotTimer: null,
notRobotPending: false,
challengeLoadedAt: 0,
externalApi: null,
externalWidgetId: null,
audioPlayer: null,
form: root.closest("form"),
};
states.set(root, state);
root.dataset.captchaSize = state.config.size;
var response = root.querySelector("[data-captcha-response]");
if (response) response.name = state.config.responseField;
var answer = root.querySelector("[data-captcha-answer]");
var verifyButton = root.querySelector("[data-captcha-verify]");
var refreshButton = root.querySelector("[data-captcha-refresh]");
var audioButton = root.querySelector("[data-captcha-audio]");
var audioAlternative = root.querySelector("[data-captcha-audio-alternative]");
var notRobotButton = root.querySelector("[data-captcha-not-robot-button]");
if (answer) {
answer.addEventListener("input", function () {
state.answer = answer.value;
emit(state, "input", { answerLength: state.answer.length });
});
answer.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
event.preventDefault();
verify(state);
}
});
}
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");
createChallenge(state, state.config.presentation);
});
if (state.form) {
state.form.addEventListener("submit", function (event) {
onFormSubmit(state, event);
}, true);
state.form.addEventListener("wire:success", function () {
createChallenge(state, state.config.presentation);
});
}
updateNotRobotState(state);
updateControls(state);
if (state.config.disabled) setStatus(state, "disabled", "");
else if (state.config.autoLoad) createChallenge(state, state.config.presentation);
}
function scan(scope) {
var host = scope || document;
if (host instanceof Element && host.matches("[data-wrn-captcha]")) initialize(host);
host.querySelectorAll("[data-wrn-captcha]").forEach(initialize);
}
var runtime = {
scan: scan,
reset: function (element) {
var root = typeof element === "string" ? document.querySelector(element) : element;
var state = root ? states.get(root) : null;
if (state) createChallenge(state, state.config.presentation);
},
verify: function (element) {
var root = typeof element === "string" ? document.querySelector(element) : element;
var state = root ? states.get(root) : null;
return state ? verify(state) : Promise.resolve();
},
};
window[RUNTIME_KEY] = runtime;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { scan(document); }, { once: true });
} else {
scan(document);
}
new MutationObserver(function (records) {
records.forEach(function (record) {
record.addedNodes.forEach(function (node) {
if (node.nodeType === 1) scan(node);
});
});
}).observe(document.documentElement, { childList: true, subtree: true });
})();
+27 -12
View File
@@ -3,6 +3,14 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const showcaseRoot = join(import.meta.dir, "..");
const repositoryRoot = join(showcaseRoot, "..", "..");
const packageComponentPath = join(
repositoryRoot,
"packages",
"captcha",
"components",
"Captcha.wrn",
);
function path(relativePath: string): string {
return join(showcaseRoot, relativePath);
@@ -19,7 +27,6 @@ describe("CAPTCHA showcase structure", () => {
"app/pages/captcha.wrn",
"app/pages/protected.wrn",
"app/pages/styles.wrn",
"app/components/Captcha.wrn",
"app/api/contact.ts",
"app/api/page-grant.ts",
"app/schemas/contact.ts",
@@ -41,7 +48,8 @@ describe("CAPTCHA showcase structure", () => {
expect(packageJson).toContain('"@iconify-json/lucide"');
expect(packageJson).toContain('"@iconify/tailwind4"');
expect(styles).toContain('@plugin "@iconify/tailwind4";');
expect(styles).toContain('@source "../../../packages/captcha/components/*.wrn";');
expect(styles).not.toContain("packages/captcha/components");
expect(read("wrnexus.config.ts")).not.toContain("includePackageSources: false");
});
test("documents every bundled challenge mode", () => {
@@ -72,7 +80,6 @@ describe("CAPTCHA showcase structure", () => {
expect(source).toContain('showListen="false"');
});
test("documents every generated image renderer and random pool controls", () => {
const source = read("app/pages/styles.wrn");
const styles = [
@@ -170,15 +177,23 @@ describe("CAPTCHA showcase integration", () => {
expect(grantRoute).toContain("Response.redirect");
});
test("registers the CAPTCHA plugin and DevToolbar audit", () => {
const source = read("wrnexus.config.ts");
test("auto-discovers the CAPTCHA package without manual plugin or script wiring", () => {
const config = read("wrnexus.config.ts");
const packageJson = read("package.json");
const component = readFileSync(packageComponentPath, "utf8");
expect(source).toContain("captchaPlugin");
expect(source).toContain("enableDevToolbar: true");
expect(packageJson).toContain('"@wrnexus/captcha": "workspace:*"');
expect(existsSync(path("app/components/Captcha.wrn"))).toBe(false);
expect(existsSync(packageComponentPath)).toBe(true);
expect(config).not.toContain("captchaPlugin");
expect(config).not.toContain("plugins:");
expect(component).toContain('data-wrnexus-runtime="captcha"');
expect(component).not.toContain("<script");
expect(component).not.toContain("captcha.js");
});
test("ships the expected component events and response field", () => {
const source = read("app/components/Captcha.wrn");
const source = readFileSync(packageComponentPath, "utf8");
const expectedEvents = [
"ready",
"challenge",
@@ -195,14 +210,14 @@ describe("CAPTCHA showcase integration", () => {
expect(source).toContain('name = "wrn-captcha-response"');
expect(source).toContain('responseField = "wrn-captcha-response"');
expect(source).toContain('disturbance = 50');
expect(source).toContain("disturbance = 50");
expect(source).toContain('size = "normal"');
expect(source).toContain('showListen = true');
expect(source).toContain("showListen = true");
expect(source).toContain('imageStyle = "random"');
expect(source).toContain('allowedStyles = ""');
expect(source).toContain('excludedStyles = ""');
expect(source).toContain('randomizeStyle = false');
expect(source).toContain('data-captcha-not-robot-button');
expect(source).toContain("randomizeStyle = false");
expect(source).toContain("data-captcha-not-robot-button");
for (const eventName of expectedEvents) {
expect(source).toContain(`@event ${eventName} = function`);
+15 -6
View File
@@ -1,10 +1,10 @@
import type { AppConfig } from "@wrnexus/styles";
import { captchaPlugin } from "@wrnexus/captcha/plugin";
const config: AppConfig & { plugins?: unknown[] } = {
const config: AppConfig = {
seo: {
title: "WRNexus CAPTCHA Showcase",
description: "Number, text, calculation, image, audio, invisible, managed, and third-party CAPTCHA examples.",
description:
"Number, text, calculation, image, audio, invisible, managed, and third-party CAPTCHA examples.",
canonicalBase: "http://localhost:3000",
robots: "noindex,nofollow",
},
@@ -12,12 +12,21 @@ const config: AppConfig & { plugins?: unknown[] } = {
styles: {
entry: "app/styles/global.css",
process: async ({ entryPath, appRoot, mode }) => {
const args = ["@tailwindcss/cli", "-i", entryPath!];
if (!entryPath) throw new Error("CAPTCHA showcase stylesheet entry was not resolved.");
const args = ["@tailwindcss/cli", "-i", entryPath];
if (mode === "production") args.push("--minify");
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
const result = await Bun.$.cwd(appRoot).nothrow()`bunx ${args}`;
if (result.exitCode !== 0) {
throw new Error(
result.stderr.toString().trim() ||
result.stdout.toString().trim() ||
"Tailwind CSS processing failed.",
);
}
return result.stdout.toString();
},
failureMode: "throw",
},
plugins: [captchaPlugin({ enableDevToolbar: true })],
};
export default config;
@@ -46,11 +46,11 @@ page AdvancedFormsShowcase {
<article class="catalog-card">
<div class="catalog-card-preview">
<div class="catalog-card-glow" aria-hidden="true"></div>
<div class="catalog-card-stage"><div data-component="InputNumber" size="md" color="primary" name="default-quantity" value="1" min="0" max="99" step="1"></div></div>
<div class="catalog-card-stage"><div data-component="InputNumber" size="md" color="primary" variant="default" name="default-quantity" value="1" min="0" max="99" step="1"></div></div>
</div>
<div class="catalog-card-body">
<div><span class="catalog-card-category">Advanced Forms</span><h2>Input Number</h2><p>Theme-aware, responsive input number component.</p></div>
<div class="catalog-card-footer"><span>13 props · 0 slots</span><a href="/components/input-number">Explore component <span aria-hidden="true">→</span></a></div>
<div class="catalog-card-footer"><span>40 props · 0 slots</span><a href="/components/input-number">Explore component <span aria-hidden="true">→</span></a></div>
</div>
</article>
<article class="catalog-card">
@@ -10,7 +10,7 @@ page BaseShowcase {
<span class="showcase-eyebrow">Component category</span>
<h1 class="showcase-title">Base</h1>
<p class="showcase-description">27 unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.</p>
<div class="category-meta"><span>27 components</span><span>Multiple use cases</span><span>81 live configurations</span></div>
<div class="category-meta"><span>27 components</span><span>Multiple use cases</span><span>86 live configurations</span></div>
</header>
<div class="catalog-grid">
<article class="catalog-card">
@@ -70,7 +70,7 @@ page ButtonDetail {
<section class="detail-section"><div class="detail-section-heading"><span>Live examples</span><h2>Designed for real product surfaces</h2><p>Compare configurations and resize the browser to check responsive behavior.</p></div><div class="demo-list">
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Recommended</span><h2>Production default</h2><p>Balanced spacing, hierarchy, and content for the most common product workflow.</p></div>
<div><span class="demo-case-eyebrow">Default</span><h2>Primary action</h2><p>The standard high-emphasis action for forms and product workflows.</p></div>
<span class="demo-case-number">01</span>
</header>
<div class="demo-workbench">
@@ -78,7 +78,7 @@ page ButtonDetail {
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Save &amp; Submit" as="button" type="submit" class="showcase-instance showcase-instance--1"></div></div>
</div>
<section class="demo-code" aria-label="Production default usage">
<section class="demo-code" aria-label="Primary action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Save &amp; Submit"
@@ -90,7 +90,7 @@ page ButtonDetail {
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Dense UI</span><h2>Compact application</h2><p>A tighter variation for dashboards, side panels, tables, and operational interfaces.</p></div>
<div><span class="demo-case-eyebrow">Outline · Small</span><h2>Secondary navigation</h2><p>A compact link button with an end icon and restrained emphasis.</p></div>
<span class="demo-case-number">02</span>
</header>
<div class="demo-workbench">
@@ -98,7 +98,7 @@ page ButtonDetail {
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Learn more" as="a" href="#button-demo" variant="outline" size="sm" icon="icon-[lucide--arrow-right]" iconPosition="end" class="showcase-instance showcase-instance--2"></div></div>
</div>
<section class="demo-code" aria-label="Compact application usage">
<section class="demo-code" aria-label="Secondary navigation usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Learn more"
@@ -114,7 +114,7 @@ page ButtonDetail {
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Extended</span><h2>Rich configuration</h2><p>A more expressive variation using additional data, stronger emphasis, and optional states.</p></div>
<div><span class="demo-case-eyebrow">Secondary · Large · Pill</span><h2>Prominent rounded action</h2><p>A large secondary action using the optional pill shape.</p></div>
<span class="demo-case-number">03</span>
</header>
<div class="demo-workbench">
@@ -122,7 +122,7 @@ page ButtonDetail {
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Upgrade plan" as="button" variant="secondary" size="lg" pill="true" class="showcase-instance showcase-instance--3"></div></div>
</div>
<section class="demo-code" aria-label="Rich configuration usage">
<section class="demo-code" aria-label="Prominent rounded action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Upgrade plan"
@@ -130,6 +130,113 @@ page ButtonDetail {
variant="secondary"
size="lg"
pill="true"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Ghost · Icon</span><h2>Accessible icon action</h2><p>An icon-only control with its accessible name supplied through props.</p></div>
<span class="demo-case-number">04</span>
</header>
<div class="demo-workbench">
<div id="button-demo-4" class="demo-canvas demo-canvas--4">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Open settings" as="button" variant="ghost" size="icon" icon="icon-[lucide--settings]" ariaLabel="Open settings" class="showcase-instance showcase-instance--4"></div></div>
</div>
<section class="demo-code" aria-label="Accessible icon action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Open settings"
as="button"
variant="ghost"
size="icon"
icon="icon-[lucide--settings]"
ariaLabel="Open settings"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Destructive</span><h2>Dangerous action</h2><p>A destructive treatment for irreversible or high-risk operations.</p></div>
<span class="demo-case-number">05</span>
</header>
<div class="demo-workbench">
<div id="button-demo-5" class="demo-canvas demo-canvas--5">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Delete project" as="button" variant="destructive" icon="icon-[lucide--trash-2]" class="showcase-instance showcase-instance--5"></div></div>
</div>
<section class="demo-code" aria-label="Dangerous action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Delete project"
as="button"
variant="destructive"
icon="icon-[lucide--trash-2]"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Link</span><h2>Inline action</h2><p>A low-chrome action that behaves and reads like an inline link.</p></div>
<span class="demo-case-number">06</span>
</header>
<div class="demo-workbench">
<div id="button-demo-6" class="demo-canvas demo-canvas--6">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Read documentation" as="a" href="#button-demo" variant="link" class="showcase-instance showcase-instance--6"></div></div>
</div>
<section class="demo-code" aria-label="Inline action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Read documentation"
as="a"
href="#button-demo"
variant="link"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Loading</span><h2>Pending action</h2><p>The button owns its busy state, loading label, spinner, and disabled behavior.</p></div>
<span class="demo-case-number">07</span>
</header>
<div class="demo-workbench">
<div id="button-demo-7" class="demo-canvas demo-canvas--7">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Publish" loadingLabel="Publishing…" as="button" loading="true" class="showcase-instance showcase-instance--7"></div></div>
</div>
<section class="demo-code" aria-label="Pending action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Publish"
loadingLabel="Publishing…"
as="button"
loading="true"
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Disabled</span><h2>Unavailable action</h2><p>A visibly and semantically disabled control.</p></div>
<span class="demo-case-number">08</span>
</header>
<div class="demo-workbench">
<div id="button-demo-8" class="demo-canvas demo-canvas--8">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><div data-component="Button" label="Unavailable action" as="button" variant="outline" disabled="true" class="showcase-instance showcase-instance--8"></div></div>
</div>
<section class="demo-code" aria-label="Unavailable action usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Button
label="Unavailable action"
as="button"
variant="outline"
disabled="true"
/&gt;</code></pre>
</section>
</div>
@@ -138,7 +245,7 @@ page ButtonDetail {
<section id="api" class="detail-section"><div class="detail-section-heading"><span>Component API</span><h2>Props and configuration</h2><p>All content and behavior shown above is supplied through these props and slots.</p></div><div class="docs-table-wrap"><table class="docs-table"><thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Required</th></tr></thead><tbody><tr><td><code>label</code></td><td>string</td><td><code>"Button"</code></td><td>No</td></tr><tr><td><code>loadingLabel</code></td><td>string</td><td><code>"Loading…"</code></td><td>No</td></tr><tr><td><code>description</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>as</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>href</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>target</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>rel</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>type</code></td><td>string</td><td><code>"button"</code></td><td>No</td></tr><tr><td><code>variant</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>color</code></td><td>string</td><td><code>"primary"</code></td><td>No</td></tr><tr><td><code>size</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>disabled</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>loading</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>pill</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>fullWidth</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>icon</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>iconPosition</code></td><td>string</td><td><code>"start"</code></td><td>No</td></tr><tr><td><code>ariaLabel</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaPressed</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaExpanded</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>ariaControls</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>title</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>autofocus</code></td><td>boolean</td><td><code>false</code></td><td>No</td></tr><tr><td><code>controlClass</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>class</code></td><td>string</td><td><code>""</code></td><td>No</td></tr></tbody></table></div></section>
</main>
<aside class="detail-aside">
<div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a><a href="#button-demo-1">Production default</a><a href="#button-demo-2">Compact application</a><a href="#button-demo-3">Rich configuration</a><a href="#api">Props API</a></div>
<div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a><a href="#button-demo-1">Primary action</a><a href="#button-demo-2">Secondary navigation</a><a href="#button-demo-3">Prominent rounded action</a><a href="#button-demo-4">Accessible icon action</a><a href="#button-demo-5">Dangerous action</a><a href="#button-demo-6">Inline action</a><a href="#button-demo-7">Pending action</a><a href="#button-demo-8">Unavailable action</a><a href="#api">Props API</a></div>
</aside>
</div>
<nav class="detail-pagination">
File diff suppressed because one or more lines are too long
@@ -17,11 +17,11 @@ page ComponentShowcase {
<section class="home-product-grid"><article><span class="icon-[lucide--component] size-7"></span><strong>85 components</strong><p>Accessible primitives for every product surface.</p><a href="/base">Browse components →</a></article><article><span class="icon-[lucide--layout-template] size-7"></span><strong>Ready-made blocks</strong><p>Composable sections assembled from WRNexus UI.</p><a href="/blocks">Explore blocks →</a></article><article><span class="icon-[lucide--panels-top-left] size-7"></span><strong>Page templates</strong><p>Complete responsive pages ready to customize.</p><a href="/templates">View templates →</a></article></section>
<section class="showcase-stats">
<div class="showcase-stat"><strong>85</strong><span>Unique components</span></div>
<div class="showcase-stat"><strong>343</strong><span>Live configurations</span></div>
<div class="showcase-stat"><strong>348</strong><span>Live configurations</span></div>
<div class="showcase-stat"><strong>0</strong><span>Duplicate implementations</span></div>
<div class="showcase-stat"><strong>8</strong><span>Focused categories</span></div>
</section>
<section class="home-categories"><div class="home-section-heading"><span>Explore the system</span><h2>Everything your product needs</h2></div><div class="home-category-grid"><a class="home-category home-category--1" href="/advanced-forms"><span class="home-category-index">01</span><div><strong>Advanced Forms</strong><p>9 components · 115 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/base"><span class="home-category-index">02</span><div><strong>Base</strong><p>27 components · 81 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/forms"><span class="home-category-index">03</span><div><strong>Forms</strong><p>11 components · 33 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/integrations"><span class="home-category-index">04</span><div><strong>Integrations</strong><p>12 components · 36 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--1" href="/layout"><span class="home-category-index">05</span><div><strong>Layout</strong><p>10 components · 30 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/navigation"><span class="home-category-index">06</span><div><strong>Navigation</strong><p>9 components · 27 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/overlays"><span class="home-category-index">07</span><div><strong>Overlays</strong><p>6 components · 18 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/tables"><span class="home-category-index">08</span><div><strong>Tables</strong><p>1 components · 3 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a></div></section>
<section class="home-categories"><div class="home-section-heading"><span>Explore the system</span><h2>Everything your product needs</h2></div><div class="home-category-grid"><a class="home-category home-category--1" href="/advanced-forms"><span class="home-category-index">01</span><div><strong>Advanced Forms</strong><p>9 components · 115 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/base"><span class="home-category-index">02</span><div><strong>Base</strong><p>27 components · 86 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/forms"><span class="home-category-index">03</span><div><strong>Forms</strong><p>11 components · 33 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/integrations"><span class="home-category-index">04</span><div><strong>Integrations</strong><p>12 components · 36 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--1" href="/layout"><span class="home-category-index">05</span><div><strong>Layout</strong><p>10 components · 30 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/navigation"><span class="home-category-index">06</span><div><strong>Navigation</strong><p>9 components · 27 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/overlays"><span class="home-category-index">07</span><div><strong>Overlays</strong><p>6 components · 18 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/tables"><span class="home-category-index">08</span><div><strong>Tables</strong><p>1 components · 3 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a></div></section>
</div>
}
}
@@ -1269,6 +1269,9 @@ function inputNumberConfiguration(variation) {
function demoUses(component) {
switch (component.name) {
case "Button":
return buttonUses;
case "AdvancedSelect":
return advancedSelectUses;
@@ -85,9 +85,9 @@ test("every component has a detail page with its expected live use cases", () =>
return [...source.matchAll(/<div data-component="([^"]+)"/g)].map((match) => match[1]);
});
// Every detail page has one playground plus three standard demos. Button,
// AdvancedSelect, ComboBox, PinInput, and TogglePassword add specialized capability demos.
expect(detailMounts).toHaveLength(reference.count * 4 + 83);
// Every detail page has one playground plus three standard demos. Interactive
// components add specialized capability demos beyond that shared baseline.
expect(detailMounts).toHaveLength(reference.count * 4 + 95);
for (const component of reference.components) {
const expectedMounts =
component.mount === "Button"
@@ -96,11 +96,15 @@ test("every component has a detail page with its expected live use cases", () =>
? 42
: component.mount === "ComboBox"
? 22
: component.mount === "PinInput"
? 19
: component.mount === "TogglePassword"
? 11
: 4;
: component.mount === "InputNumber"
? 13
: component.mount === "PinInput"
? 19
: component.mount === "StrongPassword"
? 7
: component.mount === "TogglePassword"
? 11
: 4;
expect(detailMounts.filter((mount) => mount === component.mount)).toHaveLength(expectedMounts);
}
}, 15_000);
+10 -13
View File
@@ -1,19 +1,16 @@
# Framework integration
The current WRNexusJS component scanner always includes `packages/ui/components` and the applications `app/components`. The installer therefore copies `Captcha.wrn` into `packages/ui/components/Captcha.wrn` while retaining the canonical package copy at `packages/captcha/components/Captcha.wrn`.
WRNexusJS 0.4 discovers components and browser runtimes from installed package plugins.
A future framework improvement can read component directories registered by plugins and pass them to `createRouter({ componentDirs })`. `captchaPlugin()` already exposes the package directory through configuration and plugin metadata, so the package is ready for that change without changing its public API.
`@wrnexus/captcha` is a complete example:
The installer adds these TypeScript aliases:
- `packages/captcha/package.json` declares `wrnexus.plugin`.
- `captchaPlugin()` contributes its component directory, client runtime, Tailwind source, and DevToolbar audit panel.
- `Captcha.wrn` renders `data-wrnexus-runtime="captcha"`.
- SSR injects the runtime only when the component appears.
- CSR navigation mounts and unmounts the runtime.
- Development serves the package source; production emits a hashed chunk.
```json
{
"@wrnexus/captcha": ["./packages/captcha/src/index.ts"],
"@wrnexus/captcha/server": ["./packages/captcha/src/server/index.ts"],
"@wrnexus/captcha/client": ["./packages/captcha/src/client/index.ts"],
"@wrnexus/captcha/plugin": ["./packages/captcha/src/plugin.ts"],
"@wrnexus/captcha/*": ["./packages/captcha/src/*"]
}
```
There is intentionally no `packages/ui/components/Captcha.wrn`, no showcase-local copy, and no public `captcha.js` file.
After installation, regenerate the UI component reference so `Captcha` appears in generated component documentation.
`integration/platform-upgrade.test.ts` exercises the 0.4 cross-package foundation.
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from "bun:test";
import {
HealthRegistry,
ServiceContainer,
memoryIdempotencyStore,
serviceToken,
withIdempotency,
} from "@wrnexus/core";
import { signal, watch } from "@wrnexus/reactive";
import { routeName } from "@wrnexus/router";
import { backoffDelay, retry, stableStringify } from "@wrnexus/helpers";
import { createDurableQueue } from "@wrnexus/queue";
import { PresenceChannel } from "@wrnexus/pubsub";
import { safeObjectKey, sniffContentType } from "@wrnexus/uploader";
import { auditWireTokens, contrast } from "@wrnexus/styles";
import { createPluginRunner, definePlugin, defaultClientRuntimePath } from "@wrnexus/plugin";
import { WRN_SYNTAX_VERSION, createSourceRange, sliceSource } from "@wrnexus/syntax";
describe("WRNexusJS 0.4 platform upgrades", () => {
test("publishes the 0.4 syntax contract", () => {
expect(WRN_SYNTAX_VERSION).toBe("0.4");
expect(sliceSource("abcdef", createSourceRange(1, 4))).toBe("bcd");
});
test("supports scoped services and health checks", async () => {
const token = serviceToken<{ name: string }>("example");
const root = new ServiceContainer().set(token, { name: "root" });
expect(root.scope().get(token).name).toBe("root");
const health = new HealthRegistry();
health.register("database", () => ({ status: "up" }));
expect((await health.check()).status).toBe("up");
});
test("replays idempotent work", async () => {
const store = memoryIdempotencyStore<number>();
let executions = 0;
const execute = async () => ++executions;
expect((await withIdempotency(store, "request-1", execute)).replayed).toBe(false);
expect((await withIdempotency(store, "request-1", execute)).replayed).toBe(true);
expect(executions).toBe(1);
});
test("runs watchers with cleanup", () => {
const value = signal(1);
const seen: number[] = [];
const stop = watch(
() => value.get(),
(next) => {
seen.push(next);
},
{ immediate: true },
);
value.set(2);
stop();
value.set(3);
expect(seen).toEqual([1, 2]);
});
test("provides deterministic routing and resilience helpers", async () => {
expect(routeName("/teams/[teamId]/members/[memberId]")).toBe("teams.teamId.members.memberId");
expect(stableStringify({ b: 2, a: 1 })).toBe('{"a":1,"b":2}');
expect(backoffDelay(1, { minDelayMs: 10, jitter: 0 })).toBe(10);
let attempts = 0;
const result = await retry(
async () => {
attempts++;
if (attempts < 2) throw new Error("retry");
return "ok";
},
{ attempts: 2, minDelayMs: 0, jitter: 0 },
);
expect(result).toBe("ok");
});
test("drains durable jobs and tracks presence", async () => {
const queue = createDurableQueue({ now: () => 100 });
const output: number[] = [];
queue.process<number>("number", async (job) => {
output.push(job.data);
});
await queue.add("number", 7);
expect(await queue.drain()).toBe(1);
expect(output).toEqual([7]);
let now = 1;
const presence = new PresenceChannel(10, () => now);
presence.touch("user-1");
expect(presence.list()).toHaveLength(1);
now = 12;
expect(presence.list()).toHaveLength(0);
});
test("audits uploads and styles", () => {
expect(safeObjectKey("../My Report.pdf", "docs")).toMatch(/^docs\//);
expect(sniffContentType(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))).toBe(
"image/png",
);
expect(auditWireTokens(":root{--wire-a:#fff}.x{color:var(--wire-b)}").missing).toEqual([
"--wire-b",
]);
expect(contrast("#000", "#fff")?.level).toBe("aaa");
});
test("normalizes and validates package runtimes", async () => {
expect(defaultClientRuntimePath("captcha")).toBe("/__wrnexus/assets/captcha.js");
const runner = createPluginRunner(
definePlugin({
name: "test-runtime",
clientRuntimes: [{ id: "test", source: "window.test=true" }],
}),
{
root: process.cwd(),
mode: "development",
command: "test",
metadata: new Map(),
warn: () => {},
},
);
const contributions = await runner.contributions();
expect(contributions.clientRuntimes[0]?.publicPath).toBe("/__wrnexus/assets/test.js");
});
});
+32 -3
View File
@@ -11,8 +11,9 @@
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
- **Routing is file-based** under `app/`. The filename is the route. No router config.
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
on the server and hydrate automatically — you never write client-side JS islands.
- **Application interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
on the server and hydrate automatically. Advanced reusable packages may declare a framework-managed
client runtime; applications never copy its JavaScript or add its `<script>` tag manually.
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
@@ -77,7 +78,9 @@ component Counter {
```
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
Components render on the server with their props, then hydrate — no per-component JS.
Components render on the server with their props, then hydrate. When an installed package component
needs native browser APIs, it marks its runtime with `data-wrnexus-runtime`; WRNexus injects that
runtime once and only on pages that use it.
## The `view { }` block (plain HTML + a few directives)
@@ -195,6 +198,29 @@ export default defineRoom({
```
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
## Installed systems and automatic package runtimes
WRNexus 0.4 discovers framework packages from application dependencies. A package may contribute
components, browser runtimes, assets, styles, routes, middleware, migrations, and DevToolbar panels.
Do not copy package components or public JavaScript into the application.
Example CAPTCHA usage after adding `@wrnexus/captcha`:
```wrn
<Captcha
type="alphanumeric"
action="contact-submit"
size="compact"
/>
```
The rendered component declares `data-wrnexus-runtime="captcha"`. SSR injects the hashed runtime
automatically, CSR navigation loads it when first needed, and the runtime mounts/unmounts with the page.
Server-side CAPTCHA verification is still mandatory.
Package authors declare integrations through `package.json#wrnexus` or `definePlugin()`; they should
never require consumers to edit HTML, Tailwind `@source`, static asset folders, or migration lists.
## Config (`wrnexus.config.ts`)
```ts
@@ -263,6 +289,9 @@ wrnexus create <name> # scaffold a new app
wrnexus update --latest # deps + syntax/config migrations + verification
wrnexus generate page <Name> # scaffold a page (aliases: g p)
wrnexus generate component <name> | api <path> | schema <name>
wrnexus generate system <name> # scaffold a framework-native package system
wrnexus inspect packages|plugins|routes|assets|runtimes|styles|migrations|bundle
wrnexus doctor # validate app, plugins, assets, runtimes, and migrations
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
```
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "wrnexus",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"description": "An SSR-first full-stack web framework with server-rendered reactive components. Bun-first, Node-friendly.",
@@ -26,7 +26,9 @@
"format": "prettier . --write",
"format:check": "prettier . --check",
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check",
"verify:0.3": "node scripts/verify-0.3.mjs"
"verify:0.3": "node scripts/verify-0.3.mjs",
"verify:0.4": "node scripts/verify-0.4.mjs",
"validate:0.4": "node scripts/validate-0.4.mjs"
},
"devDependencies": {
"@eslint/js": "latest",
+1 -1
View File
@@ -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.",
+2
View File
@@ -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";
+150
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.3.6",
"version": "0.4.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+92
View File
@@ -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));
}
+11
View File
@@ -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";
+15
View File
@@ -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",
});
});
+10 -4
View File
@@ -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 components `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
+268 -78
View File
@@ -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);
}
+1 -6
View File
@@ -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'>
+8 -1
View File
@@ -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
}
}
}
+8 -14
View File
@@ -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 },
};
}
+45 -11
View File
@@ -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",
+12 -3
View File
@@ -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;
}
+2 -1
View File
@@ -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",
+72 -12
View File
@@ -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),
);
}
+6 -20
View File
@@ -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 };
}
+9 -3
View File
@@ -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",
+139 -42
View File
@@ -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));
+2 -1
View File
@@ -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
View File
@@ -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;
}
+20 -7
View File
@@ -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));
+3 -2
View File
@@ -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();

Some files were not shown because too many files have changed in this diff Show More