Compare commits

...
36 Commits
Author SHA1 Message Date
ClintchizandClaude Opus 5 b807f7b5a7 fix(db): refuse to roll back a migration that defines no down section
Quality / quality (ubuntu-latest) (push) Failing after 6m4s
Quality / quality (windows-latest) (push) Canceled after 0s
rollback ran a migration's down section when it had one and then deleted
its tracking row either way. A migration that changed the schema without a
`-- +down` section was therefore recorded as rolled back while its change
stayed in the database, and the next migrate replayed statements such as
ALTER TABLE ADD COLUMN that cannot run twice, leaving the database stuck
between the two states with no way forward or back.

It now refuses that case with WRN-DB-MIGRATION-IRREVERSIBLE and leaves the
tracking row alone, so the database stays consistent and the operator
chooses: add a down section, or write a new migration that reverses the
change. The check runs before the lock is taken, so a dry run reports the
same refusal rather than promising a rollback that would fail.

A migration whose up is empty still rolls back by dropping its tracking
row, since there is nothing to undo. That keeps placeholder migrations
working, and `wrnexus db rollback` prints the refusal as a message rather
than a stack trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:57:43 +05:30
Clintchiz d3f26ad16e chore(release): publish payment 0.8.6 2026-08-28 10:02:51 +05:30
Clintchiz 1b470402c7 fix(payment): expose provider type for named gateways 2026-08-28 10:02:42 +05:30
Clintchiz 2e770f7e3e chore(release): publish payment 0.8.5 2026-08-28 09:59:21 +05:30
Clintchiz 2d77f5457c feat(payment): expose secure APIs and themed pages 2026-08-28 09:59:07 +05:30
Clintchiz dd8d6ecc3b chore(release): publish payment 0.8.4 2026-08-28 09:51:48 +05:30
Clintchiz 95c291db64 feat(payment): configure named gateway providers 2026-08-28 09:49:53 +05:30
Clintchiz c578bc0f92 fix(payment): compile shipped payment components 2026-08-28 09:42:00 +05:30
Clintchiz 10771f1a2b feat(payment): verify Razorpay checkout callbacks 2026-08-28 09:35:30 +05:30
Clintchiz b1064d442a fix(ui): avoid icon spacing for plain inputs 2026-08-27 22:53:14 +05:30
Clintchiz 6dea64b84c fix(ui): render drawer close glyph and tighten mobile heading 2026-08-26 21:12:21 +05:30
Clintchiz b1db287f2a fix(ui): allow trigger-anchored dropdowns on mobile 2026-08-26 21:06:35 +05:30
Clintchiz 9d90418490 fix(ui): defer select options and omit unused selection tags 2026-08-26 21:01:18 +05:30
Clintchiz 61a4f01321 feat(auth): require verified new-browser trust with fixed expiry 2026-08-26 19:59:22 +05:30
Clintchiz bef67b0a24 fix(auth): follow server redirect after passkey sign-in 2026-08-26 19:40:34 +05:30
Clintchiz 6123a6865e fix(ui): hide dropdown panels in server-rendered markup 2026-08-26 19:29:21 +05:30
Clintchiz 18f1d08bc3 test(auth): verify expired and fresh MFA login flows end to end 2026-08-26 19:23:52 +05:30
Clintchiz d0e0dfeb26 fix(auth): show one terminal MFA expiry state 2026-08-26 19:21:40 +05:30
Clintchiz 0c77de93b4 fix(auth): expire stale MFA forms before accepting codes 2026-08-26 19:17:48 +05:30
Clintchiz 298e34ef2e feat(auth): use MFA radio methods and request selected delivery codes 2026-08-26 18:17:02 +05:30
Clintchiz 65d63dfbe7 fix(tooling): reconcile framework fixes in canonical WireJS checkout 2026-08-26 18:10:58 +05:30
Clintchiz a077515336 fix(auth): preserve MFA continuation through field hydration 2026-08-26 18:03:02 +05:30
Clintchiz 24f15e8b78 fix(ui): render Select arrow independently of icon CSS 2026-08-26 17:59:17 +05:30
Clintchiz 43bc2330b2 fix(dev-server): keep HTTP sockets alive through proxy request deadlines 2026-08-26 17:48:49 +05:30
Clintchiz 9e0907ffb2 fix(ui): honor select search limits for large regional lists 2026-08-26 17:46:13 +05:30
Clintchiz b1b84eb41c fix(ui): style password validation errors and invalid borders 2026-08-26 17:40:01 +05:30
Clintchiz fe40cc56df fix(forms): preserve regex quantifiers and use inline constraint errors 2026-08-26 17:13:07 +05:30
Clintchiz 1dd60ded0d fix(dev-server): preserve nested component named slots 2026-08-26 15:20:54 +05:30
Clintchiz 25a421c727 fix(compiler): preserve native boolean semantics in page branches 2026-08-26 15:11:27 +05:30
Clintchiz 681679483a feat(auth): support schema-qualified SQL stores 2026-08-26 14:11:13 +05:30
Clintchiz a85cbf240d fix(dev-server): preserve serialized object component props 2026-08-26 13:48:48 +05:30
Clintchiz 26af30cfe6 fix(compiler): preserve server-only component props during hydration 2026-08-26 12:47:21 +05:30
Clintchiz bfa72d5679 fix(compiler): preserve server-only loader text and control blocks 2026-08-26 12:25:26 +05:30
Clintchiz aec1dc6056 fix(compiler): render server loader attributes without client state 2026-08-26 12:19:07 +05:30
Clintchiz 6acf78a72c fix(ui): repair sidebar drawer, dropdown focus and footer slots 2026-08-26 12:03:50 +05:30
Clintchiz f7fe3bb17b fix(auth): persist signup verification sessions and supply delivery URLs 2026-08-26 10:55:48 +05:30
92 changed files with 3474 additions and 362 deletions

No files matched your search

+7 -7
View File
@@ -235,7 +235,7 @@
},
"packages/auth": {
"name": "@wrnexus/auth",
"version": "0.8.18",
"version": "0.8.26",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/captcha": "workspace:*",
@@ -320,7 +320,7 @@
},
"packages/compiler": {
"name": "@wrnexus/compiler",
"version": "0.8.22",
"version": "0.8.27",
"dependencies": {
"@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*",
@@ -357,7 +357,7 @@
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
"version": "0.8.56",
"version": "0.8.59",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*",
@@ -532,7 +532,7 @@
},
"packages/payment": {
"name": "@wrnexus/payment",
"version": "0.8.1",
"version": "0.8.6",
"dependencies": {
"@wrnexus/db": "workspace:*",
"@wrnexus/plugin": "workspace:*",
@@ -668,7 +668,7 @@
},
"packages/syntax": {
"name": "@wrnexus/syntax",
"version": "0.8.18",
"version": "0.8.19",
},
"packages/test": {
"name": "@wrnexus/test",
@@ -680,7 +680,7 @@
},
"packages/typecheck": {
"name": "@wrnexus/typecheck",
"version": "0.8.16",
"version": "0.8.17",
"dependencies": {
"@wrnexus/syntax": "workspace:*",
"typescript": "^6.0.3",
@@ -688,7 +688,7 @@
},
"packages/ui": {
"name": "@wrnexus/ui",
"version": "0.8.56",
"version": "0.8.70",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.8.12
- Fixed false TypeScript diagnostics for `{t:key}` translations in component props and native HTML attributes. Mixed translated/dynamic attributes still validate ordinary expressions.
- Fixed inline event handlers containing multiple statements, and infer `event.currentTarget` from the bound HTML/SVG element.
- Rebuilt the bundled language server from the shared framework typechecker; real unknown-name, invalid-property and nullable DOM diagnostics remain enabled.
## 0.8.9
- Added highlighting for the page-level `apis { }` block and its `<name> <METHOD> <path>` entries,
+22
View File
@@ -4,6 +4,10 @@
"workspaces": {
"": {
"name": "wrnexus",
"dependencies": {
"vscode-html-languageservice": "^5.6.2",
"vscode-languageclient": "^10.1.0",
},
"devDependencies": {
"@vscode/vsce": "^3.9.2",
},
@@ -88,6 +92,8 @@
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.6", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og=="],
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"@vscode/vsce": ["@vscode/vsce@3.9.2", "", { "dependencies": { "@azure/identity": "^4.1.0", "@secretlint/node": "^10.1.2", "@secretlint/secretlint-formatter-sarif": "^10.1.2", "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", "chalk": "^4.1.2", "cheerio": "^1.0.0-rc.9", "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", "glob": "^13.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", "minimatch": "^10.2.2", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", "semver": "^7.5.2", "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "optionalDependencies": { "keytar": "^7.7.0" }, "bin": { "vsce": "vsce" } }, "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA=="],
"@vscode/vsce-sign": ["@vscode/vsce-sign@2.0.9", "", { "optionalDependencies": { "@vscode/vsce-sign-alpine-arm64": "2.0.6", "@vscode/vsce-sign-alpine-x64": "2.0.6", "@vscode/vsce-sign-darwin-arm64": "2.0.6", "@vscode/vsce-sign-darwin-x64": "2.0.6", "@vscode/vsce-sign-linux-arm": "2.0.6", "@vscode/vsce-sign-linux-arm64": "2.0.6", "@vscode/vsce-sign-linux-x64": "2.0.6", "@vscode/vsce-sign-win32-arm64": "2.0.6", "@vscode/vsce-sign-win32-x64": "2.0.6" } }, "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g=="],
@@ -542,6 +548,20 @@
"version-range": ["version-range@4.15.0", "", {}, "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg=="],
"vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="],
"vscode-jsonrpc": ["vscode-jsonrpc@9.0.1", "", {}, "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw=="],
"vscode-languageclient": ["vscode-languageclient@10.1.0", "", { "dependencies": { "minimatch": "^10.2.5", "semver": "^7.8.1", "vscode-languageserver-protocol": "3.18.2", "vscode-languageserver-textdocument": "1.0.13" } }, "sha512-XXRx6lqVitQy/oOLr9MfNYRG+MbQkhXkDaxbQMiKxEm8zZNfheRFUKNb8UYNh2stn9btl2wQM5wZFJjJvoc+jA=="],
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.2", "", { "dependencies": { "vscode-jsonrpc": "9.0.1", "vscode-languageserver-types": "3.18.0" } }, "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
"vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
@@ -582,6 +602,8 @@
"table/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"vscode-languageclient/vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.13", "", {}, "sha512-nx0ZHwMGIsVkzFG3/VLeJYBLTaFBRuNdGDvevvjuoayU5EOS2fEYazOhtCM3PI9ClMMg5igc0uwXtAq4tJj+Dw=="],
"@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "wrnexus",
"displayName": "WRNexus Language Support",
"description": "Complete WRNexus v0.8.3 language support for typed imports, props, state, outputs, runtime functions, stores, diagnostics, formatting, navigation, and migration assistance.",
"version": "0.8.11",
"version": "0.8.12",
"publisher": "wrnexus",
"private": true,
"license": "SEE LICENSE IN LICENSE",
+52 -13
View File
@@ -1,6 +1,6 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: 3d346a264807ba7b107584dc9a7b505b1d14057b2ad1aa85c5ea890f9b09c272
// WRN editor compiler source hash: 89199942be0196a3d06ea3d30dff80d361253192108bd978b5b80d921195c59f
// WRN editor compiler generator hash: f8c1094b47e695151205899de28cd63d574356d26ac49219c4a43db0f2391ef5
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -1202,10 +1202,16 @@ function renderAttrs(attrs, csrId, reactive = null, dynamicExpressions) {
if (expression &&
exprRefsState(expression, reactive.runtimeStateNames) &&
dynamicExpressions) {
const marker = exprRefsState(expression, reactive.stateNames)
? ` data-wrn-bind-${bindIndex++}="${attrEscape(JSON.stringify([attr.name, attr.value]))}"`
: "";
if (isHtmlBooleanAttribute(attr.name)) {
dynamicExpressions.push(`\${(${expression}) ? ${JSON.stringify(` ${attr.name}`)} : ""}`);
return `\x00WRNEACH${dynamicExpressions.length - 1}\x00${marker}`;
}
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
const marker = JSON.stringify([attr.name, attr.value]);
return ` ${attr.name}="${sentinel}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
return ` ${attr.name}="${sentinel}"${marker}`;
}
const initial = reactiveAttrValue(attr.value, reactive);
if (initial === null)
@@ -1253,17 +1259,20 @@ function evalStateSeeds(states) {
*/
function substituteReactiveText(raw, reactive, dynamicExpressions) {
const text = substituteTMarkers(raw);
if (!reactive || reactive.stateNames.size === 0)
if (!reactive)
return text;
return text.replace(/\{([^{}]+)\}/g, (whole, inner) => {
const expr = inner.trim();
if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames))
const clientReactive = exprRefsState(expr, reactive.stateNames);
if (expr.startsWith("t:"))
return whole;
if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) {
dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`);
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
return `<span data-text="${attrEscape(expr)}">${sentinel}</span>`;
return clientReactive ? `<span data-text="${attrEscape(expr)}">${sentinel}</span>` : sentinel;
}
if (!clientReactive)
return whole;
let value;
try {
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
@@ -1339,6 +1348,18 @@ function renderLoopBody(node, locals = []) {
if (attr.boolean) {
return escLit(` ${name}`);
}
// Native boolean attributes inside #if/#each must use presence semantics,
// just like attributes on the page root. disabled="false" still disables
// an HTML input. Component props retain their serialized boolean value.
if (!componentTag &&
!node.attrs.some((a) => a.name === "data-component") &&
isHtmlBooleanAttribute(name)) {
const expression = wholeAttributeExpression(attr.value);
if (expression)
return "${(" + expression + ") ? " + JSON.stringify(` ${name}`) + ' : ""}';
if (attr.value === "false")
return "";
}
return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`);
})
.join("");
@@ -1458,6 +1479,16 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
// templateEscape, swapped for its real `${…}` code after escaping.
if (node.type === "each" || node.type === "if") {
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
const conditions = node.type === "each"
? [node.list]
: node.branches.flatMap((branch) => (branch.cond ? [branch.cond] : []));
// Server-only loader values are not serialized into browser state. Their
// controls must not be re-evaluated by a surrounding component's hydration.
if (reactive &&
conditions.some((condition) => exprRefsState(condition, reactive.runtimeStateNames)) &&
!conditions.some((condition) => exprRefsState(condition, reactive.stateNames))) {
return `\x00WRNEACH${loops.length - 1}\x00`;
}
const definition = node.type === "each"
? {
list: node.list,
@@ -1621,7 +1652,7 @@ function renderNestedComponentInvocation(node, ctx) {
const wholeExpression = wholeAttributeExpression(attr.value);
const compiledValue = wholeExpression
? `\${__wrnProp(${ctx.resolveExpr(wholeExpression)})}`
: compileAttrValue(attr.value, ctx);
: compileAttrValue(attr.value, ctx, attr.name === "pattern");
const rendered = ` ${attr.name}="${compiledValue}"`;
if (!attr.value.includes("{") ||
(!exprRefsState(attr.value, ctx.stateNames) && !exprRefsState(attr.value, ctx.propNames))) {
@@ -2250,7 +2281,7 @@ function generateInner(ast) {
.map((load) => load.name),
];
const runtimeStateNames = runtimeComputedNames(orderedStates, orderedComputed, runtimeRoots);
const reactive = reactiveNames.length > 0
const reactive = reactiveNames.length > 0 || runtimeStateNames.size > 0
? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope }
: null;
const loops = [];
@@ -2739,7 +2770,7 @@ function compileText(raw, ctx) {
return out + escLit(raw.slice(last));
}
/** Compile an attribute value; `{expr}` is baked server-side (loop vars stay literal). */
function compileAttrValue(raw, ctx) {
function compileAttrValue(raw, ctx, regexPattern = false) {
if (!raw.includes("{"))
return escLit(attrEscape(raw));
let out = "";
@@ -2749,7 +2780,10 @@ function compileAttrValue(raw, ctx) {
while ((m = INTERP_RE.exec(raw))) {
out += escLit(attrEscape(raw.slice(last, m.index)));
const expr = m[1].trim();
if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
if (regexPattern && /^\d+(?:,\d*)?$/.test(expr)) {
out += escLit(attrEscape(m[0])); // Regex quantifiers are not template expressions.
}
else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
out += escLit(`{${expr}}`); // hydrated per-item by the list renderer
}
else {
@@ -3047,7 +3081,7 @@ function renderComponentNode(node, ctx) {
}
const compiledValue = isExplicitComponentMount && wholeExpression
? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}`
: compileAttrValue(a.value, elementContext);
: compileAttrValue(a.value, elementContext, a.name === "pattern");
/*
* A constraint attribute whose value is only known at render time (a
* component prop defaulting to "") must be decided at render time, so
@@ -3528,7 +3562,7 @@ function __wrnProp(v) {
const value = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v == null ? "" : v);
return __wrnAttr(value);
}
function renderPageComponentAttr(attr, dynamicExpressions, _reactive) {
function renderPageComponentAttr(attr, dynamicExpressions, reactive) {
if (attr.event) {
return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
}
@@ -3541,6 +3575,11 @@ function renderPageComponentAttr(attr, dynamicExpressions, _reactive) {
}
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
if (reactive &&
exprRefsState(expression, reactive.runtimeStateNames) &&
!exprRefsState(expression, reactive.stateNames)) {
return ` ${attr.name}="${marker}"`;
}
// The mount disappears during SSR. Preserve every dynamic component prop
// as a parent-owned binding; server-only expressions simply fail closed in
// the browser, while page state can continue driving the mounted child.
@@ -6229,7 +6268,7 @@ function expandStructuredStateDeclarations(lines, unit) {
});
}
function formatStructuredPropDeclaration(value, unit, depth, printWidth) {
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([\[{][\s\S]*)$/.exec(value);
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([[{][\s\S]*)$/.exec(value);
if (!match)
return null;
try {
+71 -121
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: d07c535bd69332e90f207cbc254aae06982b40dd863fbc8de63fb386ecf31f50
// WRN editor extension source hash: fd585db47099c8b781bf49e4ce7c86146bad583f781d0aa93196eb200a95b20f
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
@@ -10089,7 +10089,7 @@ var require_commonjs = __commonJS(function(exports2) {
// editors/vscode/node_modules/brace-expansion/dist/commonjs/index.js
var require_commonjs2 = __commonJS(function(exports2) {
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.EXPANSION_MAX_LENGTH = exports2.EXPANSION_MAX = undefined;
exports2.EXPANSION_MAX = undefined;
exports2.expand = expand;
var balanced_match_1 = require_commonjs();
var escSlash = "\x00SLASH" + Math.random() + "\x00";
@@ -10108,7 +10108,6 @@ var require_commonjs2 = __commonJS(function(exports2) {
var commaPattern = /\\,/g;
var periodPattern = /\\\./g;
exports2.EXPANSION_MAX = 1e5;
exports2.EXPANSION_MAX_LENGTH = 4000000;
function numeric(str) {
return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0);
}
@@ -10142,11 +10141,11 @@ var require_commonjs2 = __commonJS(function(exports2) {
if (!str) {
return [];
}
const { max = exports2.EXPANSION_MAX, maxLength = exports2.EXPANSION_MAX_LENGTH } = options;
const { max = exports2.EXPANSION_MAX } = options;
if (str.slice(0, 2) === "{}") {
str = "\\{\\}" + str.slice(2);
}
return expand_(escapeBraces(str), max, maxLength, true).map(unescapeBraces);
return expand_(escapeBraces(str), max, true).map(unescapeBraces);
}
function embrace(str) {
return "{" + str + "}";
@@ -10160,87 +10159,20 @@ var require_commonjs2 = __commonJS(function(exports2) {
function gte(i, y) {
return i >= y;
}
function combine(acc, pre, values, max, maxLength, dropEmpties) {
const out = [];
let length = 0;
for (let a = 0;a < acc.length; a++) {
for (let v = 0;v < values.length; v++) {
if (out.length >= max)
return out;
const expansion = acc[a] + pre + values[v];
if (dropEmpties && !expansion)
continue;
if (length + expansion.length > maxLength)
return out;
out.push(expansion);
length += expansion.length;
}
}
return out;
}
function expandSequence(body, isAlphaSequence, max, maxLength) {
const n = body.split(/\.\./);
const N = [];
if (n[0] === undefined || n[1] === undefined) {
return N;
}
const x = numeric(n[0]);
const y = numeric(n[1]);
const width = Math.max(n[0].length, n[1].length);
let incr = n.length === 3 && n[2] !== undefined ? Math.max(Math.abs(numeric(n[2])), 1) : 1;
let test = lte;
const reverse = y < x;
if (reverse) {
incr *= -1;
test = gte;
}
const pad = n.some(isPadded);
let length = 0;
for (let i = x;test(i, y) && N.length < max; i += incr) {
let c;
if (isAlphaSequence) {
c = String.fromCharCode(i);
if (c === "\\") {
c = "";
}
} else {
c = String(i);
if (pad) {
const need = width - c.length;
if (need > 0) {
const z = new Array(need + 1).join("0");
if (i < 0) {
c = "-" + z + c.slice(1);
} else {
c = z + c;
}
}
}
}
if (length + c.length > maxLength)
break;
N.push(c);
length += c.length;
}
return N;
}
function expand_(str, max, maxLength, isTop) {
let acc = [""];
let dropEmpties = false;
let firstGroup = true;
function expand_(str, max, isTop) {
const expansions = [];
for (;; ) {
const m = (0, balanced_match_1.balanced)("{", "}", str);
if (!m) {
return combine(acc, str, [""], max, maxLength, dropEmpties);
}
if (!m)
return [str];
const pre = m.pre;
if (/\$$/.test(pre)) {
acc = combine(acc, pre + "{" + m.body + "}", [""], max, maxLength, dropEmpties && !m.post.length);
firstGroup = false;
if (!m.post.length)
break;
str = m.post;
continue;
if (/\$$/.test(m.pre)) {
const post2 = m.post.length ? expand_(m.post, max, false) : [""];
for (let k = 0;k < post2.length && k < max; k++) {
const expansion = pre + "{" + m.body + "}" + post2[k];
expansions.push(expansion);
}
return expansions;
}
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
@@ -10252,56 +10184,74 @@ var require_commonjs2 = __commonJS(function(exports2) {
isTop = true;
continue;
}
return combine(acc, pre + "{" + m.body + "}" + m.post, [""], max, maxLength, dropEmpties);
return [str];
}
if (firstGroup) {
dropEmpties = isTop && !isSequence;
firstGroup = false;
}
let values;
const post = m.post.length ? expand_(m.post, max, false) : [""];
let n;
if (isSequence) {
values = expandSequence(m.body, isAlphaSequence, max, maxLength);
n = m.body.split(/\.\./);
} else {
let n = parseCommaParts(m.body);
n = parseCommaParts(m.body);
if (n.length === 1 && n[0] !== undefined) {
n = expand_(n[0], max, maxLength, false).map(embrace);
n = expand_(n[0], max, false).map(embrace);
if (n.length === 1) {
acc = combine(acc, pre + n[0], [""], max, maxLength, dropEmpties && !m.post.length);
if (!m.post.length)
break;
str = m.post;
continue;
return post.map((p) => m.pre + n[0] + p);
}
}
let dropsEmpties = dropEmpties && !m.post.length && !pre;
for (let d = 0;dropsEmpties && d < acc.length; d++) {
if (acc[d]) {
dropsEmpties = false;
}
}
let N;
if (isSequence && n[0] !== undefined && n[1] !== undefined) {
const x = numeric(n[0]);
const y = numeric(n[1]);
const width = Math.max(n[0].length, n[1].length);
let incr = n.length === 3 && n[2] !== undefined ? Math.max(Math.abs(numeric(n[2])), 1) : 1;
let test = lte;
const reverse = y < x;
if (reverse) {
incr *= -1;
test = gte;
}
values = [];
let valuesLength = 0;
outer:
for (let j = 0;j < n.length; j++) {
const expanded = expand_(n[j], max, maxLength, false);
for (let k = 0;k < expanded.length; k++) {
const v = expanded[k];
if (dropsEmpties && !v)
continue;
if (values.length >= max || valuesLength + v.length > maxLength) {
break outer;
const pad = n.some(isPadded);
N = [];
for (let i = x;test(i, y) && N.length < max; i += incr) {
let c;
if (isAlphaSequence) {
c = String.fromCharCode(i);
if (c === "\\") {
c = "";
}
} else {
c = String(i);
if (pad) {
const need = width - c.length;
if (need > 0) {
const z = new Array(need + 1).join("0");
if (i < 0) {
c = "-" + z + c.slice(1);
} else {
c = z + c;
}
}
values.push(v);
valuesLength += v.length;
}
}
N.push(c);
}
} else {
N = [];
for (let j = 0;j < n.length; j++) {
N.push.apply(N, expand_(n[j], max, false));
}
}
acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length);
if (!m.post.length)
break;
str = m.post;
for (let j = 0;j < N.length; j++) {
for (let k = 0;k < post.length && expansions.length < max; k++) {
const expansion = pre + N[j] + post[k];
if (!isTop || isSequence || expansion) {
expansions.push(expansion);
}
}
}
return expansions;
}
return acc;
}
});
@@ -13933,7 +13883,7 @@ var require_configuration = __commonJS(function(exports2) {
exports2.SyncConfigurationFeature = SyncConfigurationFeature;
});
// editors/vscode/node_modules/vscode-languageserver-textdocument/lib/umd/main.js
// editors/vscode/node_modules/vscode-languageclient/node_modules/vscode-languageserver-textdocument/lib/umd/main.js
var require_main2 = __commonJS(function(exports2, module2) {
var __spreadArray = exports2 && exports2.__spreadArray || function(to, from, pack) {
if (pack || arguments.length === 2)
+6 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// WRN editor language server source hash: 5258b3e76cec47df752d4bb886fbd57a3f3c4af9ca9b6b2bf6742c8d78025ff1
// WRN editor language server source hash: d192eefe86b41eaa54eb48a69202842dbeb8ec4ee4e962c912b288820a65b162
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -170723,7 +170723,7 @@ function expandStructuredStateDeclarations(lines, unit) {
});
}
function formatStructuredPropDeclaration(value, unit, depth, printWidth) {
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([\[{][\s\S]*)$/.exec(value);
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([[{][\s\S]*)$/.exec(value);
if (!match)
return null;
try {
@@ -173217,12 +173217,13 @@ ${sharedAliases}`);
append(`declare const ${name}: typeof ${runtimeNamespace(runtime)}.${name};`);
}
let bindingIndex = 0;
append("type __WrnEventElement<Tag extends string> = Tag extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[Tag] : Tag extends keyof SVGElementTagNameMap ? SVGElementTagNameMap[Tag] : HTMLElement;");
const reservedProps = new Set(ast.props.map((prop) => prop.name).filter((name) => !safeBindingName(name)));
const appendViewBindings = (nodes, locals) => {
for (const node of nodes) {
if (node.type === "element") {
for (const attr of node.attrs) {
const expressions = attr.event ? [attr.value] : extractInterpolations(attr.value).map((segment) => segment.expression);
const expressions = attr.event ? [attr.value] : extractInterpolations(attr.value).filter((segment) => !/^t:[^{}]+$/.test(segment.expression)).map((segment) => segment.expression);
for (const expression of expressions) {
if (!expression.trim())
continue;
@@ -173230,10 +173231,10 @@ ${sharedAliases}`);
...[...locals].map((name) => `declare const ${name}: any;`),
...attr.event ? [
"declare const payload: any;",
"declare const event: Event & { target: HTMLElement };"
`declare const event: Event & { target: HTMLElement; currentTarget: __WrnEventElement<${JSON.stringify(node.tag)}> };`
] : []
].join(" ");
append(`namespace __wrn_view_${bindingIndex++} { ${declarations} void (${safeViewExpression(expression, reservedProps)}); }`, attr.value);
append(`namespace __wrn_view_${bindingIndex++} { ${declarations} ${attr.event ? `void (() => { ${safeViewExpression(expression, reservedProps)} });` : `void (${safeViewExpression(expression, reservedProps)});`} }`, attr.value);
}
}
appendViewBindings(node.children, locals);
+2 -2
View File
@@ -29,8 +29,8 @@ test("formats large typed JSON prop defaults with the bundled formatter", () =>
}`;
const formatted = formatWrn(source, { insertSpaces: true, tabSize: 2, printWidth: 70 });
assert.match(formatted, /categories: unknown\[\] = \[\n \{/);
assert.match(formatted, /"apps": \[\n \{/);
assert.match(formatted, /categories: unknown\[\] = \[\n {6}\{/);
assert.match(formatted, /"apps": \[\n {10}\{/);
assert.equal(formatWrn(formatted, { insertSpaces: true, tabSize: 2, printWidth: 70 }), formatted);
});
@@ -41,6 +41,51 @@ function run(messages) {
return parseResponses(result.stdout || Buffer.alloc(0));
}
test("bundled language server accepts translated attributes and form statement handlers", () => {
const uri = "file:///tmp/wrnexus-translated-contact.wrn";
const valid = `page Contact {
view {
<div data-component="PageHeader" eyebrow="{t:contact.eyebrow}" title="{t:contact.title}" description="{t:contact.description}"></div>
<form @wrn:success="event.currentTarget.reset(); toast.success('Sent')"></form>
}
}`;
const responses = run([
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { rootPath: root } },
{ jsonrpc: "2.0", method: "initialized", params: {} },
{
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri, languageId: "wrn", version: 1, text: valid } },
},
{
jsonrpc: "2.0",
method: "textDocument/didChange",
params: {
textDocument: { uri, version: 2 },
contentChanges: [
{ text: valid.replace("{t:contact.title}", "{t:contact.title} {missingValue}") },
],
},
},
{ jsonrpc: "2.0", method: "textDocument/didSave", params: { textDocument: { uri } } },
{ jsonrpc: "2.0", id: 2, method: "shutdown", params: {} },
{ jsonrpc: "2.0", method: "exit", params: {} },
]);
const publications = responses.filter(
(item) => item.method === "textDocument/publishDiagnostics",
);
assert.equal(publications.length, 2);
assert.deepEqual(
publications[0].params.diagnostics.filter((item) => item.source === "wrnexus-types"),
[],
);
const errors = publications[1].params.diagnostics.filter(
(item) => item.source === "wrnexus-types",
);
assert.equal(errors.length, 1);
assert.match(errors[0].message, /missingValue/);
});
test("language server starts under Node and keeps official typed components clean", () => {
const files = [
path.join(root, "packages", "ui", "components", "AuthForm.wrn"),
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 0.8.19
- Pass the trusted HTTP origin into registration delivery, so signup verification links are populated without custom URL callbacks.
- Add `onSignUp` as an alias for `onSuccessfulSignUp`, rejecting ambiguous duplicate hooks.
- Add a `verificationSession` signup action and database-backed, hashed, purpose-bound verification cookies. Sessions survive engine reloads, do not authenticate users, and expire after 30 minutes by default.
- Verification resend resolves the pending cookie's user before considering an anonymous identifier.
## 0.5.0
- Added `AuthEngineOptions.tokenUrl` for custom verification, reset, magic-link, and invitation page URLs without application-owned API handlers.
+51
View File
@@ -1,5 +1,56 @@
# @wrnexus/auth
### New-browser verification and device trust
Enable `requireTrustedDeviceVerification: true` to require a verified email or
SMS challenge before an untrusted browser receives a sign-in session. The HTTP
handlers use a host-only HttpOnly random cookie, ignoring posted fingerprints.
Direct engine callers must supply a trusted server-side browser fingerprint.
`trustSignupDevice: true` grants the registration browser one-time trust after
required account verification, at its first successful sign-in.
Use `trustedDeviceTtlMs: 30 * 86400_000` for 30-day trust. Ordinary logins do not
renew it. The verification form offers explicit trust consent; expiry/revocation
requires a new email/SMS challenge. Successful login alerts use both verified
contact channels when configured. Delivery still requires an application adapter.
### Signup business logic and verification-only sessions
```ts
const auth = createAuthEngine({
store: () => new SqlAuthStore(getDb()),
secret: process.env.AUTH_SECRET!,
requireVerifiedEmail: true,
onSignUp(ctx, user) {
// Application business logic can run here after successful registration.
return { verificationSession: true, redirectTo: "/verify-email" };
},
delivery: {
async send(message) {
// message.channel, template, destination, user, expiresAt
// Verification uses message.token and message.url; OTP flows use message.code.
// Render your branded content here before passing it to your provider.
},
},
});
```
`onSuccessfulSignUp` remains supported; configure only one signup hook. Hooks run
from the HTTP registration handler and may return an action or a custom Response.
The request origin (or configured `auth.baseUrl`) supplies signup link URLs; a
client-supplied `baseUrl` is never trusted. Direct `engine.register` callers may
provide a trusted `baseUrl` explicitly.
The verification cookie is HttpOnly, SameSite=Lax, Secure on HTTPS, and host-only.
Its opaque token is stored hashed in the auth token store, separate from login
sessions; it cannot bypass email verification, MFA, or account policy. Use
`getVerificationUser(ctx, auth)` to guard your verification page and
`clearVerificationSession(ctx, auth)` after verification. Use
`startVerificationSession(ctx, auth, user.id)` only after credential validation
when an unverified user signs in again. SQL-backed sessions survive server reloads;
memory stores remain development/test-only. No email or SMS provider is configured
automatically.
Framework-native authentication, identity, account-security, and session management for WRNexusJS.
## Capabilities
+153 -53
View File
@@ -137,10 +137,15 @@
element.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
}
function navigate(target) {
function navigate(target, allowCrossOrigin = false) {
if (!target) return false;
const url = new URL(target, location.href);
if (url.origin !== location.origin) return false;
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return false;
if (url.origin !== location.origin) {
if (!allowCrossOrigin) return false;
location.assign(url.href);
return true;
}
if (window.__wrnexusNavigate) {
window.__wrnexusNavigate(url.pathname + url.search + url.hash);
} else {
@@ -262,6 +267,9 @@
);
setState(element, "success", element.dataset.successMessage || "Signed in.");
dispatch(element, "auth:passkey-authenticated", result);
// The same-origin verification endpoint resolves and validates the final
// destination, including cross-app SSO handoffs. Never SPA-route those.
if (typeof result.redirect === "string" && navigate(result.redirect, true)) return;
if (element.dataset.redirect) navigate(element.dataset.redirect);
} catch (error) {
if (error && error.name === "AbortError") return;
@@ -405,83 +413,175 @@
const form = element.querySelector("[data-auth-mfa-form]");
const tokenInput = form?.querySelector("[name=mfaToken]");
const challengeInput = form?.querySelector("[name=challengeId]");
const methodInput = form?.querySelector("[data-auth-mfa-method]");
const codeInput = form?.querySelector("[name=code]");
const radios = Array.from(form?.querySelectorAll("input[type=radio][name=method]") || []);
const status = form?.querySelector("[data-auth-mfa-status]");
const saved = readMfaState();
if (tokenInput && !tokenInput.value && saved?.mfaToken) {
tokenInput.value = String(saved.mfaToken);
}
const allowed = Array.isArray(saved?.requires?.mfa) ? saved.requires.mfa : [];
const initialMethod = element.dataset.mfaInitialMethod;
if (methodInput && allowed.length) {
Array.from(methodInput.options).forEach((option) => {
option.disabled = !allowed.includes(option.value);
option.hidden = option.disabled;
const challenges = new Map();
const pending = new Map();
let expired = false;
let disposed = false;
let expiryTimer;
const expiresAt = Number(saved?.mfaExpiresAt);
const expire = () => {
if (disposed) return;
expired = true;
window.clearTimeout(expiryTimer);
sessionStorage.removeItem(MFA_STORAGE_KEY);
if (tokenInput) tokenInput.value = "";
form?.querySelectorAll("input, button").forEach((control) => {
control.disabled = true;
});
const selected = allowed.includes(initialMethod)
? initialMethod
: allowed.includes(methodInput.value)
? methodInput.value
: allowed[0];
if (selected) methodInput.value = selected;
} else if (methodInput && initialMethod) {
methodInput.value = initialMethod;
}
const onMethodChange = () => {
if (challengeInput) challengeInput.value = "";
// A form response's finalizer or hydration may re-enable buttons. Hide the
// entire stale form instead of leaving a second apparently usable action.
element.dataset.mfaExpired = "true";
if (form) form.hidden = true;
setMessage(status, "");
if (codeInput) {
codeInput.value = "";
codeInput.setAttribute(
"inputmode",
methodInput?.value === "recovery-code" ? "text" : "numeric",
);
}
form?.querySelectorAll("[data-error]").forEach((error) => {
error.textContent = "";
});
const expiredMessage = element.querySelector("[data-auth-mfa-expired]");
if (expiredMessage) expiredMessage.hidden = false;
const restart = element.querySelector("[data-auth-mfa-restart]");
if (restart) restart.hidden = false;
};
const checkExpiry = () => {
// Old stored challenges have no verifiable deadline: require fresh sign-in.
if (saved?.mfaToken && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) expire();
return expired;
};
const restoreToken = () => {
checkExpiry();
if (!expired && tokenInput && !tokenInput.value && saved?.mfaToken)
tokenInput.value = String(saved.mfaToken);
};
const method = () => radios.find((radio) => radio.checked && !radio.disabled)?.value;
const isOtp = (value) => ["email-otp", "sms-otp"].includes(value);
for (const radio of radios) {
radio.disabled = allowed.length > 0 && !allowed.includes(radio.value);
const label = radio.closest("label");
if (label) label.hidden = radio.disabled;
}
const initial =
radios.find((radio) => !radio.disabled && radio.value === element.dataset.mfaInitialMethod) ||
radios.find((radio) => !radio.disabled);
for (const radio of radios) radio.checked = radio === initial;
restoreToken();
const onSubmit = async (event) => {
const method = methodInput?.value;
if (!form || !["email-otp", "sms-otp"].includes(method) || challengeInput?.value) return;
event.preventDefault();
event.stopImmediatePropagation();
const sendCode = async (selected) => {
if (disposed || expired || !isOtp(selected)) return;
restoreToken();
if (expired) return;
if (!tokenInput?.value) {
setMessage(status, "Sign-in verification has expired. Start again.", true);
const restart = element.querySelector("[data-auth-mfa-restart]");
if (restart) restart.hidden = false;
return;
}
setFormBusy(form, true);
setMessage(status, "Sending a one-time code…");
const previous = challenges.get(selected);
if (previous?.expiresAt && Number(previous.expiresAt) <= Date.now())
challenges.delete(selected);
if (!pending.has(selected) && !challenges.has(selected)) {
pending.set(
selected,
json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
mfaToken: tokenInput.value,
method: selected,
}),
);
}
if (method() === selected) {
setFormBusy(form, true);
setMessage(status, "Sending a one-time code…");
}
try {
const result = await json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
mfaToken: tokenInput.value,
method,
});
const result = challenges.get(selected) || (await pending.get(selected));
if (disposed || expired) return;
challenges.set(selected, result);
if (method() !== selected) return;
if (challengeInput) challengeInput.value = String(result.id || "");
setMessage(status, "A one-time code has been sent.");
codeInput?.focus();
} catch (error) {
setMessage(
status,
error instanceof Error ? error.message : "Unable to send a one-time code",
true,
selected === "email-otp"
? "A code has been sent to your email."
: "A code has been sent to your mobile number.",
);
} catch (error) {
if (!disposed && !expired && method() === selected)
setMessage(
status,
error instanceof Error ? error.message : "Unable to send a one-time code",
true,
);
} finally {
setFormBusy(form, false);
pending.delete(selected);
if (!disposed && !expired && method() === selected) setFormBusy(form, false);
}
};
const updateMethod = () => {
if (checkExpiry()) return;
const selected = method();
setFormBusy(form, false);
if (challengeInput) challengeInput.value = "";
const code = form?.querySelector("[name=code]");
if (code) code.value = "";
const error = form?.querySelector("[data-error=code]");
if (error) error.textContent = "";
setMessage(status, "");
for (const [selector, active] of [
["[data-auth-mfa-pin]", selected !== "recovery-code"],
["[data-auth-mfa-recovery]", selected === "recovery-code"],
]) {
const group = form?.querySelector(selector);
if (!group) continue;
group.hidden = !active;
for (const input of group.querySelectorAll("input")) {
input.disabled = !active;
input.value = "";
input.dispatchEvent(new window.Event("input", { bubbles: true }));
}
}
if (isOtp(selected)) void sendCode(selected);
};
const onMethodChange = (event) => {
if (radios.includes(event.target)) updateMethod();
};
const onSubmit = (event) => {
restoreToken();
const code = form?.querySelector("[name=code]");
const active = form?.querySelector(
method() === "recovery-code" ? "[name=recoveryCode]" : "[name=otpCode]",
);
if (code && active) code.value = active.value;
if (expired || !method() || (isOtp(method()) && !challengeInput?.value)) {
event.preventDefault();
event.stopImmediatePropagation();
if (!expired && isOtp(method())) void sendCode(method());
}
};
const onSuccess = (event) => {
if (event.target !== form) return;
sessionStorage.removeItem(MFA_STORAGE_KEY);
if (event.target === form) sessionStorage.removeItem(MFA_STORAGE_KEY);
};
onMethodChange();
methodInput?.addEventListener("change", onMethodChange);
const onError = (event) => {
if (event.target !== form || event.detail?.code !== "mfa-transaction-expired") return;
expire();
};
updateMethod();
if (!expired && Number.isFinite(expiresAt)) {
expiryTimer = window.setTimeout(expire, Math.max(0, expiresAt - Date.now()));
}
// Capture native change before the shared Radio emits its component output.
form?.addEventListener("change", onMethodChange, true);
form?.addEventListener("submit", onSubmit, true);
element.addEventListener("wrn:success", onSuccess);
element.addEventListener("wrn:error", onError);
flowCleanups.set(element, () => {
methodInput?.removeEventListener("change", onMethodChange);
disposed = true;
window.clearTimeout(expiryTimer);
form?.removeEventListener("change", onMethodChange, true);
form?.removeEventListener("submit", onSubmit, true);
element.removeEventListener("wrn:success", onSuccess);
element.removeEventListener("wrn:error", onError);
});
}
@@ -1,4 +1,4 @@
import { Button, PinInput, Select } from "@wrnexus/ui"
import { Button, Input, PinInput, Radio, Checkbox } from "@wrnexus/ui"
component TwoFactorChallenge {
props {
@@ -13,6 +13,7 @@ component TwoFactorChallenge {
description = "Choose a verification method and enter the code."
submitLabel = "Verify"
recoveryHref = "/recover"
signInHref = "/login"
color = "primary"
size = "md"
class = ""
@@ -39,15 +40,30 @@ component TwoFactorChallenge {
<input type="hidden" name="mfaToken" value='{mfaToken}' />
<input type="hidden" name="challengeId" value='{challengeId}' />
<input type="hidden" name="returnTo" value='{returnTo}' />
<Select id="auth-mfa-method" name="method" data-auth-mfa-method label="Verification method" value="{method}" options="{methodOptions}" color="{color}" size="{size}" />
<PinInput label="Verification code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
<input type="hidden" name="code" value="" />
<Radio id="auth-mfa-method" name="method" data-auth-mfa-method label="Verification method" value="{method}" options="{methodOptions}" card="true" color="{color}" size="{size}" />
<div data-auth-mfa-pin><PinInput label="Verification code" name="otpCode" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" /></div>
<div data-auth-mfa-recovery hidden><Input label="Recovery code" name="recoveryCode" autocomplete="off" maxlength="32" color="{color}" size="{size}" /></div>
<p data-error="code" class="m-0 text-sm text-[var(--wrn-color-danger)]" aria-live="polite"></p>
<Checkbox name="rememberDevice" label="Trust this device for 30 days" value="true" />
<p data-auth-mfa-status role="status" hidden class="m-0 rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-primary)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-primary)]"></p>
<p data-error="mfaToken" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-danger)]"></p>
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
</form>
<a href='{recoveryHref}' class="mt-4 inline-flex text-sm text-[var(--wrn-color-primary)] hover:underline">Account recovery options</a>
<p data-auth-mfa-expired role="alert" hidden class="wrn-auth-mfa-expired">Your sign-in request has expired. Sign in again to get a fresh verification request.</p>
<nav class="wrn-auth-mfa-links" aria-label="Sign-in help">
<a href='{signInHref}' data-auth-mfa-restart hidden>Back to sign in</a>
<a href='{recoveryHref}'>Account recovery options</a>
</nav>
</section>
}
style {
[data-auth-mfa-challenge][data-mfa-expired="true"] [data-auth-mfa-form] { display: none; }
.wrn-auth-mfa-expired { margin: 1.5rem 0 0; padding: 1rem; border: 1px solid var(--wrn-color-danger); border-radius: var(--wrn-radius-sm); background: color-mix(in srgb, var(--wrn-color-danger) 10%, transparent); color: var(--wrn-color-danger); font-size: 0.875rem; line-height: 1.5; }
.wrn-auth-mfa-links { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem; margin-top: 1.25rem; }
.wrn-auth-mfa-links a { color: var(--wrn-color-primary); font-size: 0.875rem; }
.wrn-auth-mfa-links a:hover { text-decoration: underline; }
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.8.18",
"version": "0.8.26",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
+175 -33
View File
@@ -45,13 +45,18 @@ const DEFAULT_TOKEN_TTLS: Record<AuthTokenPurpose, number> = {
"change-email": 30 * 60_000,
"change-phone": 15 * 60_000,
"login-mfa": 5 * 60_000,
"verification-session": 30 * 60_000,
};
export interface AuthEngine {
readonly deviceTrustPolicyEnabled: boolean;
readonly store: AuthStore;
readonly onSignedIn?: AuthSignedInHandler;
readonly onSignedOut?: AuthSignedOutHandler;
readonly onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
readonly onSignUp?: AuthSuccessfulSignUpHandler;
createVerificationSession(userId: string): Promise<string>;
validateVerificationSession(token: string): Promise<ReturnType<typeof publicUser> | undefined>;
register(input: RegisterInput): Promise<AuthResult>;
login(input: LoginInput): Promise<AuthResult>;
logout(sessionId: string, reason?: string): Promise<void>;
@@ -97,7 +102,10 @@ export interface AuthEngine {
token: string,
input: { password?: string; displayName?: string },
): Promise<AuthResult>;
consumeMagicLink(token: string, sessionInput?: Partial<AuthSession>): Promise<AuthResult>;
consumeMagicLink(
token: string,
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
): Promise<AuthResult>;
issueOtp(
userId: string,
method: "email-otp" | "sms-otp",
@@ -116,18 +124,20 @@ export interface AuthEngine {
completeOtpLogin(
challengeId: string,
code: string,
sessionInput?: Partial<AuthSession>,
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
): Promise<AuthResult>;
beginMfaOtp(
mfaToken: string,
method: "email-otp" | "sms-otp",
): Promise<{ id: string; expiresAt: number } | undefined>;
completeMfa(input: {
rememberDevice?: boolean;
fingerprint?: string;
mfaToken: string;
method: "totp" | "recovery-code" | "email-otp" | "sms-otp";
code: string;
challengeId?: string;
session?: Partial<AuthSession>;
session?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string };
}): Promise<AuthResult>;
beginTotp(
userId: string,
@@ -149,6 +159,7 @@ export interface AuthEngine {
provider: string,
profile: OAuthProfile,
tokens?: OAuthTokens,
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
): Promise<AuthResult>;
unlinkOAuth(userId: string, accountId: string): Promise<boolean>;
beginPasskeyRegistration(
@@ -172,7 +183,7 @@ export interface AuthEngine {
key: string;
response: unknown;
/** Request metadata used only for the resulting session. */
session?: Partial<AuthSession>;
session?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string };
}): Promise<AuthResult>;
changePassword(
userId: string,
@@ -264,6 +275,9 @@ function passkeyChallengeTtl(timeout: number): number {
}
export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (options.onSignUp && options.onSuccessfulSignUp) {
throw new TypeError("Configure either onSignUp or onSuccessfulSignUp, not both");
}
if (options.secret.length < MIN_SECRET_LENGTH) {
throw new TypeError(`auth secret must be at least ${MIN_SECRET_LENGTH} characters`);
}
@@ -474,6 +488,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
user: AuthUser,
metadata: Record<string, unknown> = {},
): Promise<AuthResult | undefined> {
const deviceCheck = await newDeviceRequirement(user, metadata);
if (deviceCheck) return deviceCheck;
if (!(await hasMfa(user.id))) return undefined;
const methods = await availableMfaMethods(user.id);
if (!methods.length) {
@@ -483,6 +499,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
message: "Additional verification is required but no verification method is available",
};
}
const mfaExpiresAt = now() + 5 * 60_000;
const mfaToken = await issueToken(user.id, "login-mfa", {
ttlMs: 5 * 60_000,
metadata,
@@ -493,6 +510,55 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
message: "Complete two-step verification to finish signing in.",
user: publicUser(user),
mfaToken,
mfaExpiresAt,
requires: { mfa: methods },
};
}
async function newDeviceRequirement(
user: AuthUser,
metadata: Record<string, unknown> = {},
): Promise<AuthResult | undefined> {
if (!options.requireTrustedDeviceVerification) return;
const browser = typeof metadata.fingerprint === "string" ? metadata.fingerprint : undefined;
if (browser) {
const hash = await fingerprint(browser);
const existing = await store.findTrustedDeviceByFingerprint(user.id, hash);
if (existing && !existing.revokedAt && existing.expiresAt > now()) return;
// Only the original registration browser can claim this one-time trust.
// Account-state / required-contact checks run before this helper.
if (options.trustSignupDevice && user.metadata?.signupDeviceHash === hash) {
const { signupDeviceHash: _signup, ...rest } = user.metadata;
user.metadata = rest;
await store.updateUser(user);
await engine.trustDevice(user.id, {
fingerprint: browser,
name: String(metadata.deviceName || "Signup browser"),
});
return;
}
}
const methods = (await availableMfaMethods(user.id)).filter(
(method) => method === "email-otp" || method === "sms-otp",
);
if (!methods.length)
return {
ok: false,
code: "device-verification-unavailable",
message:
"A verified email address or mobile number is required to verify this device. Use account recovery.",
};
const mfaExpiresAt = now() + 5 * 60_000;
const mfaToken = await issueToken(user.id, "login-mfa", {
ttlMs: 5 * 60_000,
metadata: { ...metadata, deviceVerification: true, rememberDevice: false },
});
return {
ok: false,
code: "mfa-required",
message: "Verify this new device using email or SMS. You can then trust it for 30 days.",
mfaToken,
mfaExpiresAt,
requires: { mfa: methods },
};
}
@@ -656,24 +722,26 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
async function sendLoginAlert(user: AuthUser, session: AuthSession): Promise<void> {
if (!options.sendLoginAlerts || !options.delivery) return;
const identities = await store.listIdentities(user.id);
const destination =
identities.find((item) => item.type === "email" && item.primary)?.value ??
identities.find((item) => item.type === "email")?.value;
if (!destination) return;
await deliver({
channel: "email",
template: "login-alert",
destination,
user: publicUser(user),
expiresAt: session.expiresAt,
data: {
sessionId: session.id,
deviceId: session.deviceId,
ip: session.ip,
userAgent: session.userAgent,
createdAt: session.createdAt,
},
});
for (const type of ["email", "phone"] as const) {
const destination =
identities.find((item) => item.type === type && item.verifiedAt && item.primary)?.value ??
identities.find((item) => item.type === type && item.verifiedAt)?.value;
if (!destination) continue;
await deliver({
channel: type === "phone" ? "sms" : "email",
template: "login-alert",
destination,
user: publicUser(user),
expiresAt: session.expiresAt,
data: {
sessionId: session.id,
deviceId: session.deviceId,
ip: session.ip,
userAgent: session.userAgent,
createdAt: session.createdAt,
},
});
}
}
async function createSession(
@@ -844,10 +912,28 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
}
const engine: AuthEngine = {
deviceTrustPolicyEnabled: options.requireTrustedDeviceVerification === true,
store,
onSignedIn: options.onSignedIn,
onSignedOut: options.onSignedOut,
onSuccessfulSignUp: options.onSuccessfulSignUp,
onSuccessfulSignUp: options.onSignUp ?? options.onSuccessfulSignUp,
onSignUp: options.onSignUp ?? options.onSuccessfulSignUp,
async createVerificationSession(userId) {
const user = await store.findUserById(userId);
if (!user || !["pending", "active"].includes(user.status) || user.emailVerified) {
throw new Error("Email verification session is unavailable");
}
return issueToken(user.id, "verification-session");
},
async validateVerificationSession(raw) {
if (!raw || raw.length > 512) return undefined;
const token = await inspectToken(raw, "verification-session");
if (!token) return undefined;
const user = await store.findUserById(token.userId);
if (!user || !["pending", "active"].includes(user.status) || user.emailVerified)
return undefined;
return publicUser(user);
},
async register(input) {
try {
@@ -880,7 +966,12 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
timezone: input.timezone,
createdAt: timestamp,
updatedAt: timestamp,
metadata: input.metadata,
metadata: {
...input.metadata,
...(options.trustSignupDevice && input.fingerprint
? { signupDeviceHash: await fingerprint(input.fingerprint) }
: {}),
},
};
await store.createUser(user);
if (input.email) await createIdentity(user.id, "email", input.email, true);
@@ -897,8 +988,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const identities = await store.listIdentities(user.id);
const email = identities.find((item) => item.type === "email");
const phone = identities.find((item) => item.type === "phone");
if (email) await sendToken(user, "verify-email", email.value);
if (phone) await sendToken(user, "verify-phone", phone.value);
if (email) await sendToken(user, "verify-email", email.value, input.baseUrl);
if (phone) await sendToken(user, "verify-phone", phone.value, input.baseUrl);
await audit({ userId: user.id, type: "account.registered", severity: "info" });
return {
ok: true,
@@ -1036,6 +1127,13 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return { ...verification, risk };
const mfaEnabled = await hasMfa(user.id);
const deviceCheck = await newDeviceRequirement(user, {
fingerprint: input.fingerprint,
deviceName: input.deviceName,
ip: input.ip,
userAgent: input.userAgent,
});
if (deviceCheck) return deviceCheck;
const trustedDevice = input.fingerprint
? await store.findTrustedDeviceByFingerprint(user.id, await fingerprint(input.fingerprint))
: undefined;
@@ -1057,6 +1155,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
risk,
};
}
const mfaExpiresAt = now() + 5 * 60_000;
const mfaToken = await issueToken(user.id, "login-mfa", {
ttlMs: 5 * 60_000,
metadata: {
@@ -1075,11 +1174,12 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
user: publicUser(user),
risk,
mfaToken,
mfaExpiresAt,
requires: { captcha: risk.requireCaptcha, mfa: methods },
};
}
const rememberedDevice =
input.rememberDevice && input.fingerprint
input.rememberDevice && input.fingerprint && !trusted
? await engine.trustDevice(user.id, {
fingerprint: input.fingerprint,
name: input.deviceName,
@@ -1468,6 +1568,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return verification;
const mfa = await mfaRequirement(user, {
fingerprint: sessionInput.fingerprint,
deviceName: sessionInput.deviceName,
ip: sessionInput.ip,
userAgent: sessionInput.userAgent,
deviceId: sessionInput.deviceId,
@@ -1573,6 +1675,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return verification;
const mfa = await mfaRequirement(user, {
fingerprint: sessionInput.fingerprint,
deviceName: sessionInput.deviceName,
ip: sessionInput.ip,
userAgent: sessionInput.userAgent,
deviceId: sessionInput.deviceId,
@@ -1602,7 +1706,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
await restoreExpiredLoginLock(user);
if (accountStatusUnavailable(user) || verificationUnavailable(user)) return undefined;
try {
return await issueOtp(token.userId, method, undefined, "mfa");
if (!(await availableMfaMethods(user.id)).includes(method)) return undefined;
const key = "otp:" + method;
const previousId = token.metadata?.[key];
if (typeof previousId === "string") {
const previous = await store.findOtp(previousId);
if (previous && !previous.usedAt && previous.expiresAt > now())
return { id: previous.id, expiresAt: previous.expiresAt };
}
const challenge = await issueOtp(token.userId, method, undefined, "mfa");
token.metadata = { ...token.metadata, [key]: challenge.id };
await store.updateToken(token);
return challenge;
} catch {
return undefined;
}
@@ -1623,6 +1738,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (unavailable) return unavailable;
const verification = verificationUnavailable(transactionUser);
if (verification) return verification;
if (
transaction.metadata?.deviceVerification === true &&
(input.fingerprint !== transaction.metadata.fingerprint ||
!input.fingerprint ||
!["email-otp", "sms-otp"].includes(input.method))
) {
return {
ok: false,
code: "mfa-invalid",
message: "Verify this device using email or SMS in the browser that started sign-in.",
};
}
let verified = false;
if (input.method === "totp")
verified = await engine.verifyTotp(transaction.userId, input.code);
@@ -1633,7 +1760,9 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (
otp?.userId === transaction.userId &&
otp.method === input.method &&
otp.purpose === "mfa"
otp.purpose === "mfa" &&
(transaction.metadata?.deviceVerification !== true ||
transaction.metadata?.["otp:" + input.method] === input.challengeId)
) {
verified = (await engine.verifyOtp(input.challengeId, input.code, "mfa")).ok;
}
@@ -1654,7 +1783,10 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const user = transactionUser;
const metadata = transaction.metadata ?? {};
const rememberedDevice =
metadata.rememberDevice === true && typeof metadata.fingerprint === "string"
(metadata.deviceVerification === true
? input.rememberDevice === true
: metadata.rememberDevice === true || input.rememberDevice === true) &&
typeof metadata.fingerprint === "string"
? await engine.trustDevice(user.id, {
fingerprint: metadata.fingerprint,
name: typeof metadata.deviceName === "string" ? metadata.deviceName : undefined,
@@ -1688,6 +1820,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
severity: "info",
data: { method: input.method },
});
await sendLoginAlert(user, session);
return { ok: true, user: publicUser(user), session };
},
@@ -1882,7 +2015,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
return account;
},
async loginWithOAuth(provider, profile, tokens) {
async loginWithOAuth(provider, profile, tokens, sessionInput = {}) {
const providerName = normalizedOAuthProvider(provider);
const providerAccountId = normalizedOAuthAccountId(profile.id);
let account = await store.findOAuthAccount(providerName, providerAccountId);
@@ -1935,21 +2068,28 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return verification;
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
const deviceCheck = await newDeviceRequirement(user, {
...sessionInput,
oauthProvider: providerName,
});
if (deviceCheck) return deviceCheck;
if (await hasMfa(user.id)) {
const methods = await availableMfaMethods(user.id);
const mfaExpiresAt = now() + 5 * 60_000;
const mfaToken = await issueToken(user.id, "login-mfa", {
ttlMs: 5 * 60_000,
metadata: { oauthProvider: providerName },
metadata: { ...sessionInput, oauthProvider: providerName },
});
return {
ok: false,
code: "mfa-required",
user: publicUser(user),
mfaToken,
mfaExpiresAt,
requires: { mfa: methods },
};
}
const session = await createSession(user.id);
const session = await createSession(user.id, sessionInput);
user.lastLoginAt = now();
user.updatedAt = now();
await store.updateUser(user);
@@ -2145,6 +2285,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
credential.lastUsedAt = now();
await store.updatePasskey(credential);
}
const deviceCheck = await newDeviceRequirement(user, { ...input.session });
if (deviceCheck) return deviceCheck;
const session = await createSession(user.id, input.session);
user.lastLoginAt = now();
user.updatedAt = now();
+33 -1
View File
@@ -12,6 +12,7 @@ import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from
import type { AuthSessionVerificationHandler } from "../types.ts";
import { completeAuth, startAuth, type OAuthProvider } from "@wrnexus/oauth";
import { assignDefaultAuthzRoles } from "@wrnexus/authz/defaults";
import { startVerificationSession, getVerificationUser } from "../verification-session.ts";
function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
@@ -63,6 +64,26 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const onSignedOut = engine.onSignedOut;
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
function browserDevice(ctx: Context) {
if (!engine.deviceTrustPolicyEnabled) return {};
const cookie = "wrnexus.auth.device";
let value = ctx.cookies.get(cookie);
if (!value || !/^[a-f0-9-]{36}$/.test(value)) {
value = crypto.randomUUID();
ctx.cookies.set(cookie, value, {
httpOnly: true,
secure: ctx.url.protocol === "https:",
sameSite: "Lax",
path: "/",
maxAge: 30 * 24 * 60 * 60,
});
}
return {
fingerprint: value,
deviceName: (ctx.req.headers.get("user-agent") || "Browser").slice(0, 120),
};
}
function oauthProvider(ctx: Context): OAuthProvider | undefined {
return options.oauth?.[String(ctx.params.provider ?? "").toLowerCase()];
}
@@ -147,6 +168,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
provider.name,
completed.profile,
completed.tokens,
browserDevice(ctx),
);
if (result.code === "mfa-required" && result.mfaToken) {
ctx.cookies.transaction("wrnexus.auth.mfa", { sameSite: "Lax", maxAge: 300 }).set({
@@ -180,6 +202,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.register({
...browserDevice(ctx),
baseUrl: options.baseUrl ?? ctx.url.origin,
email: text(input.email) || undefined,
phone: text(input.phone) || undefined,
username: text(input.username) || undefined,
@@ -194,9 +218,11 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const action = await onSuccessfulSignUp?.(ctx, result.user);
if (action instanceof Response) return action;
if (action?.verificationSession) await startVerificationSession(ctx, engine, result.user.id);
if (action?.autoSignIn) {
const identifier = text(input.email) || text(input.phone) || text(input.username);
const loginResult = await engine.login({
...browserDevice(ctx),
identifier,
password: text(input.password),
ip: ctx.ip,
@@ -226,6 +252,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
fingerprint: text(input.deviceFingerprint) || undefined,
deviceName: text(input.deviceName) || undefined,
rememberDevice: boolean(input.rememberDevice),
...browserDevice(ctx),
captchaVerified:
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
ctx.locals.captchaVerified === true,
@@ -254,7 +281,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const validation = await parseBody(schemas.verificationRequest, ctx.req);
if (!validation.ok) return validation.response;
const type = text(validation.value.type) === "phone" ? "phone" : "email";
const current = getAuthUser(ctx);
const current = getAuthUser(ctx) ?? (await getVerificationUser(ctx, engine));
const identifier = text(validation.value.identifier);
const user =
current ?? (identifier ? await engine.findUserByIdentifier(identifier) : undefined);
@@ -337,6 +364,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.consumeMagicLink(text(input.token), {
...browserDevice(ctx),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
@@ -363,6 +391,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.completeOtpLogin(text(input.challengeId), text(input.code), {
...browserDevice(ctx),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
@@ -497,6 +526,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
: "totp";
const result = await engine.completeMfa({
...browserDevice(ctx),
rememberDevice: boolean(input.rememberDevice),
mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
method,
code: text(input.code),
@@ -615,6 +646,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
key: text(input.key),
response: input.response,
session: {
...browserDevice(ctx),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
},
+7
View File
@@ -1,7 +1,14 @@
export { createAuthEngine, type AuthEngine } from "./engine.ts";
export {
startVerificationSession,
getVerificationUser,
clearVerificationSession,
VERIFICATION_SESSION_COOKIE,
} from "./verification-session.ts";
export type { AuthStore } from "./store.ts";
export { MemoryAuthStore } from "./stores/memory.ts";
export { SqlAuthStore } from "./stores/sql.ts";
export type { SqlAuthStoreOptions } from "./stores/sql.ts";
export {
authSession,
requireAuth,
+6
View File
@@ -1,4 +1,10 @@
export { createAuthEngine, type AuthEngine } from "../engine.ts";
export {
startVerificationSession,
getVerificationUser,
clearVerificationSession,
VERIFICATION_SESSION_COOKIE,
} from "../verification-session.ts";
export {
createAuthHttpHandlers,
type AuthHttpOptions,
+23 -2
View File
@@ -34,10 +34,31 @@ function placeholders(sql: string, dialect: string): string {
return sql.replace(/\?/g, () => `$${++index}`);
}
export interface SqlAuthStoreOptions {
/** PostgreSQL schema owning authentication tables. */
schema?: string;
/** Defaults to wrn_auth_; use an empty prefix for e.g. sso.users. */
tablePrefix?: string;
}
export class SqlAuthStore implements AuthStore {
constructor(private readonly db: Db) {}
constructor(
private readonly db: Db,
private readonly options: SqlAuthStoreOptions = {},
) {
if (options.schema && !/^[a-z_][a-z0-9_]*$/i.test(options.schema))
throw new Error("Invalid auth schema identifier");
if (options.tablePrefix && !/^[a-z_][a-z0-9_]*$/i.test(options.tablePrefix))
throw new Error("Invalid auth table prefix");
if (options.schema && db.driver.dialect !== "postgres")
throw new Error("Auth schema requires PostgreSQL");
}
private sql(value: string): string {
return placeholders(value, this.db.driver.dialect);
const qualified = value.replace(/\bwrn_auth_([a-z_]+)\b/g, (_match, table: string) => {
const name = (this.options.tablePrefix ?? "wrn_auth_") + table;
return this.options.schema ? '"' + this.options.schema + '"."' + name + '"' : name;
});
return placeholders(qualified, this.db.driver.dialect);
}
private async one(sql: string, params: unknown[] = []): Promise<Row | undefined> {
return (await this.db.one(this.sql(sql), params)) ?? undefined;
+16 -1
View File
@@ -9,7 +9,8 @@ export type AuthTokenPurpose =
| "invite"
| "change-email"
| "change-phone"
| "login-mfa";
| "login-mfa"
| "verification-session";
export type AuthMfaMethod = "email-otp" | "sms-otp" | "totp" | "recovery-code" | "passkey";
export type AuthAccountStatus = "pending" | "active" | "locked" | "disabled" | "deleted";
export type AuthRiskLevel = "low" | "medium" | "high" | "critical";
@@ -293,6 +294,8 @@ export type AuthSessionVerificationHandler = (
) => Response | Promise<Response>;
export interface AuthSuccessfulSignUpAction {
/** Issue a short-lived verification-only cookie backed by the auth token store. */
verificationSession?: boolean;
/**
* Run the normal login policy with the newly registered credentials and
* establish a session when verification, CAPTCHA, MFA, and account policy
@@ -425,6 +428,8 @@ export interface AuthEngineOptions {
* redirects to /sign-in.
*/
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
/** Alias for onSuccessfulSignUp. Configure only one signup hook. */
onSignUp?: AuthSuccessfulSignUpHandler;
breachProvider?: PasswordBreachProvider;
passkeys?: PasskeyProvider;
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
@@ -433,6 +438,10 @@ export interface AuthEngineOptions {
sessionTtlMs?: number;
sessionAbsoluteTtlMs?: number;
trustedDeviceTtlMs?: number;
/** Require verified email/SMS confirmation before a new browser receives a session. */
requireTrustedDeviceVerification?: boolean;
/** Remember the registration browser after required account verification succeeds. */
trustSignupDevice?: boolean;
tokenTtlMs?: Partial<Record<AuthTokenPurpose, number>>;
/**
* Customize action links delivered with one-time tokens. Returning undefined
@@ -467,6 +476,10 @@ export interface AuthEngineOptions {
}
export interface RegisterInput {
fingerprint?: string;
deviceName?: string;
/** Trusted server-side origin for verification links; never copied from request data. */
baseUrl?: string;
email?: string;
phone?: string;
username?: string;
@@ -498,6 +511,8 @@ export interface AuthResult {
session?: AuthSession;
risk?: AuthRiskDecision;
mfaToken?: string;
/** Absolute expiry of the pending sign-in challenge, in epoch milliseconds. */
mfaExpiresAt?: number;
requires?: {
captcha?: boolean;
mfa?: AuthMfaMethod[];
+1
View File
@@ -160,6 +160,7 @@ export const mfaOtpRequestSchema = v.object({
});
export const mfaSchema = v.object({
rememberDevice: v.boolean().optional(),
mfaToken: v.string().min(20, "MFA transaction is invalid").max(512).optional(),
method: v
.string()
+36
View File
@@ -0,0 +1,36 @@
import type { Context } from "@wrnexus/core";
import type { AuthEngine } from "./engine.ts";
export const VERIFICATION_SESSION_COOKIE = "wrnexus.auth.verification";
function cookieOptions(ctx: Context) {
return {
httpOnly: true,
secure: ctx.url.protocol === "https:",
sameSite: "Lax" as const,
path: "/",
};
}
/** Call only after successful registration or credential validation, never from a public identifier. */
export async function startVerificationSession(
ctx: Context,
engine: AuthEngine,
userId: string,
): Promise<void> {
await clearVerificationSession(ctx, engine);
const token = await engine.createVerificationSession(userId);
ctx.cookies.set(VERIFICATION_SESSION_COOKIE, token, cookieOptions(ctx));
}
/** Reads the database-backed, purpose-bound session; grants no authenticated app access. */
export async function getVerificationUser(ctx: Context, engine: AuthEngine) {
const token = ctx.cookies.get(VERIFICATION_SESSION_COOKIE);
return token ? engine.validateVerificationSession(token) : undefined;
}
export async function clearVerificationSession(ctx: Context, engine: AuthEngine): Promise<void> {
const token = ctx.cookies.get(VERIFICATION_SESSION_COOKIE);
if (token && token.length <= 512) await engine.consumeToken(token, "verification-session");
ctx.cookies.delete(VERIFICATION_SESSION_COOKIE, cookieOptions(ctx));
}
+4 -3
View File
@@ -92,7 +92,7 @@ test("authentication journeys use themed UI form controls", () => {
"MagicLinkSignIn.wrn": ["<Input", "<Button"],
"InvitationAccept.wrn": ["<Input", "<StrongPassword", "<Button"],
"OtpSignIn.wrn": ["<Input", "<PinInput", "<Button"],
"TwoFactorChallenge.wrn": ["<Select", "<PinInput", "<Button"],
"TwoFactorChallenge.wrn": ["<Radio", "<Input", "<PinInput", "<Button"],
"AuthenticatorSetup.wrn": ["<Input", "<PinInput", "<Button"],
"VerifyEmail.wrn": ["<Input", "<Button"],
"VerifyPhone.wrn": ["<PinInput", "<Button"],
@@ -114,8 +114,9 @@ test("verification components can resend without application-owned schema files"
}
});
test("auth browser runtime refuses cross-origin navigation targets", () => {
test("auth browser runtime refuses cross-origin navigation unless explicitly server-approved", () => {
const source = readFileSync(join(directory, "..", "assets", "client", "auth.js"), "utf8");
expect(source).toContain("if (url.origin !== location.origin) return false");
expect(source).toContain("function navigate(target, allowCrossOrigin = false)");
expect(source).toContain("if (!allowCrossOrigin) return false");
expect(source).toContain("!window.AbortController");
});
+222
View File
@@ -0,0 +1,222 @@
import { expect, test } from "bun:test";
import { createAuthEngine } from "../src/engine.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import { createAuthHttpHandlers } from "../src/http/index.ts";
import { createContext } from "@wrnexus/core";
import type { AuthDeliveryMessage } from "../src/types.ts";
test("magic-link, OTP, and passkey sign-in cannot bypass new-device verification", async () => {
const messages: AuthDeliveryMessage[] = [];
let userId = "";
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "all-flows-trust-secret-at-least-32-characters",
requireVerifiedEmail: true,
requireTrustedDeviceVerification: true,
delivery: {
async send(message) {
messages.push(message);
},
},
passkeys: {
async registrationOptions() {
throw new Error("Not used");
},
async verifyRegistration() {
throw new Error("Not used");
},
async authenticationOptions() {
return {
challenge: "AQID",
rpId: "example.test",
timeout: 60000,
userVerification: "required" as const,
};
},
async verifyAuthentication() {
return { verified: true, userId };
},
},
});
userId = (await engine.register({ email: "flows@example.test", password: "StrongPassword123!" }))
.user!.id;
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
await engine.requestMagicLink("flows@example.test");
const magic = messages.find((m) => m.template === "magic-link")!.token!;
expect((await engine.consumeMagicLink(magic, { fingerprint: "browser" })).code).toBe(
"mfa-required",
);
const otp = await engine.requestOtpLogin("flows@example.test", "email-otp");
expect(
(
await engine.completeOtpLogin(
otp.id,
messages.filter((m) => m.template === "email-otp").at(-1)!.code!,
{ fingerprint: "browser" },
)
).code,
).toBe("mfa-required");
const passkey = await engine.beginPasskeyAuthentication({
identifier: "flows@example.test",
rpId: "example.test",
origin: "https://example.test",
});
expect(
(
await engine.finishPasskeyAuthentication({
key: passkey.key,
response: {},
session: { fingerprint: "browser" },
})
).code,
).toBe("mfa-required");
expect(await engine.listSessions(userId)).toHaveLength(0);
});
test("new browsers require bound email/SMS verification; signup trust expires without sliding", async () => {
let now = 1_780_000_000_000;
const messages: AuthDeliveryMessage[] = [];
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "test-browser-trust-secret-at-least-32-characters",
clock: { now: () => now },
requireVerifiedEmail: true,
requireTrustedDeviceVerification: true,
trustSignupDevice: true,
trustedDeviceTtlMs: 30 * 86400_000,
sendLoginAlerts: true,
delivery: {
async send(message) {
messages.push(message);
},
},
});
const registered = await engine.register({
email: "trust@example.test",
phone: "+15551234567",
password: "StrongPassword123!",
fingerprint: "signup-browser",
});
const userId = registered.user!.id;
expect(
(
await engine.login({
identifier: "trust@example.test",
password: "StrongPassword123!",
fingerprint: "signup-browser",
})
).code,
).toBe("email-unverified");
expect(await engine.listTrustedDevices(userId)).toHaveLength(0);
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
await engine.verifyPhone(messages.find((m) => m.template === "verify-phone")!.token!);
const login = (fingerprint: string, rememberDevice = false) =>
engine.login({
identifier: "trust@example.test",
password: "StrongPassword123!",
fingerprint,
rememberDevice,
});
expect((await login("signup-browser")).session?.trusted).toBe(true);
const firstExpiry = (await engine.listTrustedDevices(userId))[0]!.expiresAt;
now += 86400_000;
expect((await login("signup-browser", true)).ok).toBe(true);
expect((await engine.listTrustedDevices(userId))[0]!.expiresAt).toBe(firstExpiry);
const before = (await engine.listSessions(userId)).length;
const pending = await login("new-browser", true);
expect(pending.code).toBe("mfa-required");
expect(pending.requires?.mfa).toEqual(["email-otp", "sms-otp"]);
expect((await engine.listSessions(userId)).length).toBe(before);
const challenge = await engine.beginMfaOtp(pending.mfaToken!, "email-otp");
const count = messages.length;
expect(await engine.beginMfaOtp(pending.mfaToken!, "email-otp")).toEqual(challenge);
expect(messages.length).toBe(count);
const code = messages.filter((m) => m.template === "email-otp").at(-1)!.code!;
const complete = {
mfaToken: pending.mfaToken!,
method: "email-otp" as const,
challengeId: challenge!.id,
code,
fingerprint: "new-browser",
};
expect((await engine.completeMfa({ ...complete, fingerprint: "other-browser" })).ok).toBe(false);
expect((await engine.completeMfa({ ...complete, method: "totp" })).ok).toBe(false);
expect((await engine.completeMfa(complete)).ok).toBe(true);
expect((await engine.listTrustedDevices(userId)).length).toBe(1); // Explicit opt-in only.
expect((await engine.completeMfa(complete)).ok).toBe(false);
const again = await login("new-browser");
const sms = await engine.beginMfaOtp(again.mfaToken!, "sms-otp");
const smsCode = messages.filter((m) => m.template === "sms-otp").at(-1)!.code!;
expect(
(
await engine.completeMfa({
mfaToken: again.mfaToken!,
method: "sms-otp",
challengeId: sms!.id,
code: smsCode,
fingerprint: "new-browser",
rememberDevice: true,
})
).session?.trusted,
).toBe(true);
expect(
messages
.filter((m) => m.template === "login-alert")
.slice(-2)
.map((m) => m.channel),
).toEqual(["email", "sms"]);
const second = (await engine.listTrustedDevices(userId)).find(
(d) => d.expiresAt !== firstExpiry,
)!;
await engine.revokeTrustedDevice(userId, second.id);
expect((await login("new-browser")).code).toBe("mfa-required");
now = firstExpiry + 1;
expect((await login("signup-browser")).code).toBe("mfa-required");
});
test("HTTP signup binds trust to an HttpOnly cookie, not a supplied device fingerprint", async () => {
const messages: AuthDeliveryMessage[] = [];
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "http-device-trust-secret-at-least-32-characters",
requireVerifiedEmail: true,
requireTrustedDeviceVerification: true,
trustSignupDevice: true,
delivery: {
async send(message) {
messages.push(message);
},
},
});
const url = new URL("https://sso.example.test/api/auth/register");
const ctx = createContext(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
displayName: "Trust Test",
consent: true,
email: "http@example.test",
password: "StrongPassword123!",
deviceFingerprint: "attacker",
}),
}),
url,
);
await createAuthHttpHandlers({ engine }).register(ctx);
const cookie = ctx.cookies.headers().find((c) => c.startsWith("wrnexus.auth.device="))!;
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("Secure");
expect(cookie).toContain("Max-Age=2592000");
expect(cookie).not.toContain("attacker");
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
expect(
(
await engine.login({
identifier: "http@example.test",
password: "StrongPassword123!",
fingerprint: "attacker",
})
).code,
).toBe("mfa-required");
});
+1
View File
@@ -863,6 +863,7 @@ describe("authentication engine", () => {
requires: { mfa: ["totp"] },
});
expect(await engine.beginMfaOtp(firstLogin.mfaToken!, "email-otp")).toBeUndefined();
expect(firstLogin.mfaExpiresAt).toBe(now() + 5 * 60_000);
expect((await engine.verifyEmail(verificationToken)).ok).toBe(true);
const secondLogin = await engine.login({
+1 -1
View File
@@ -15,7 +15,7 @@ function context(request: Request): Context {
t: (key: string) => key,
ip: "127.0.0.1",
user: null,
cookies: {} as Context["cookies"],
cookies: { get: () => undefined } as unknown as Context["cookies"],
localStorage: {} as Context["localStorage"],
session: {
id: () => "http-test-session",
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from "bun:test";
import { createContext } from "@wrnexus/core";
import { createAuthEngine } from "../src/engine.ts";
import { createAuthHttpHandlers } from "../src/http/index.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import { generateTotp } from "../src/totp/index.ts";
test("HTTP login creates a usable MFA challenge; expired requests require a new login", async () => {
let time = 1_720_000_000_000;
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "mfa-full-flow-test-secret-longer-than-thirty-two-characters",
clock: { now: () => time },
});
const user = await engine.register({ email: "flow@example.test", password: "StrongPassword123" });
const setup = await engine.beginTotp(user.user!.id);
expect(
await engine.confirmTotp(
user.user!.id,
setup.credentialId,
await generateTotp(setup.secret, { timestamp: time }),
),
).toBe(true);
const handlers = createAuthHttpHandlers({ engine });
const context = (path: string, body: unknown) => {
const req = new Request(`https://sso.example.test${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return createContext(req, new URL(req.url));
};
const login = async () => {
const response = await handlers.login(
context("/api/auth/login", {
identifier: "flow@example.test",
password: "StrongPassword123",
}),
);
expect(response.status).toBe(401);
const result = await response.json();
expect(result.code).toBe("mfa-required");
expect(result.mfaExpiresAt).toBe(time + 300_000);
return result;
};
const old = await login();
time += 300_001;
const expired = await handlers.completeMfa(
context("/api/auth/mfa/complete", {
mfaToken: old.mfaToken,
method: "totp",
code: await generateTotp(setup.secret, { timestamp: time }),
}),
);
expect((await expired.json()).code).toBe("mfa-transaction-expired");
const fresh = await login();
const success = await handlers.completeMfa(
context("/api/auth/mfa/complete", {
mfaToken: fresh.mfaToken,
method: "totp",
code: await generateTotp(setup.secret, { timestamp: time }),
}),
);
expect(success.status).toBe(200);
expect(await success.json()).toMatchObject({ ok: true, user: { id: user.user!.id } });
});
+143
View File
@@ -0,0 +1,143 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { Window, type HTMLInputElement, type HTMLElement } from "happy-dom";
test("expired and legacy MFA requests are rejected on mount before entering a code", async () => {
for (const deadline of [undefined, Date.now() - 1]) {
const window = new Window({ url: "https://sso.example.test/mfa" });
try {
window.sessionStorage.setItem(
"wrnexus.auth.mfa",
JSON.stringify({ mfaToken: "old-challenge", mfaExpiresAt: deadline }),
);
window.document.body.innerHTML = `<section data-auth-mfa-challenge><form data-auth-mfa-form>
<input name="mfaToken"><input type="radio" name="method" value="totp"><input name="code"><p data-auth-mfa-status hidden></p><button type="submit">Verify</button>
</form><p data-auth-mfa-expired role="alert" hidden>Sign in again</p><a data-auth-mfa-restart hidden href="/login">Back to sign in</a></section>`;
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
(window as any).WRNexusAuth.mount(window.document);
expect(window.document.querySelector("button")!.disabled).toBe(true);
expect(window.document.querySelector<HTMLElement>("[data-auth-mfa-restart]")!.hidden).toBe(
false,
);
expect(window.document.querySelector<HTMLElement>("[data-auth-mfa-expired]")!.hidden).toBe(
false,
);
expect(window.document.querySelector("form")!.hidden).toBe(true);
expect(window.document.querySelector("[data-auth-mfa-status]")!.textContent).toBe("");
expect(window.sessionStorage.getItem("wrnexus.auth.mfa")).toBeNull();
} finally {
await window.happyDOM.close();
}
}
});
test("selecting delivery radios sends once, ignores stale responses, and never sends for local methods", async () => {
const window = new Window({ url: "https://sso.example.test/mfa" });
try {
window.sessionStorage.setItem(
"wrnexus.auth.mfa",
JSON.stringify({ mfaToken: "synthetic-test-challenge", mfaExpiresAt: Date.now() + 300_000 }),
);
window.document.body.innerHTML = `<section data-auth-mfa-challenge data-mfa-initial-method="totp"><form data-auth-mfa-form>
<input name="mfaToken"><input name="challengeId"><input name="code">
${["totp", "recovery-code", "email-otp", "sms-otp"].map((value) => `<label><input type="radio" name="method" value="${value}">${value}</label>`).join("")}
<div data-auth-mfa-pin><input name="otpCode"></div><div data-auth-mfa-recovery hidden><input name="recoveryCode"></div>
<p data-auth-mfa-status></p><button type="submit">Verify</button>
</form></section>`;
const requests: Array<{ method: string; resolve: (response: Response) => void }> = [];
window.fetch = ((_: unknown, init: RequestInit) =>
new Promise<Response>((resolve) => {
requests.push({ method: JSON.parse(String(init.body)).method, resolve });
})) as any;
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
(window as any).WRNexusAuth.mount(window.document);
const form = window.document.querySelector("form")!;
const field = (name: string) => form.querySelector<HTMLInputElement>(`[name=${name}]`)!;
const choose = (value: string) => {
const radio = form.querySelector<HTMLInputElement>(`[name=method][value="${value}"]`)!;
radio.checked = true;
radio.dispatchEvent(new window.Event("change", { bubbles: true }));
};
choose("recovery-code");
expect(requests).toHaveLength(0);
field("recoveryCode").value = "abc123-def456";
form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
expect(field("code").value).toBe("abc123-def456");
choose("email-otp");
choose("email-otp");
expect(requests.map((x) => x.method)).toEqual(["email-otp"]);
choose("sms-otp");
expect(requests.map((x) => x.method)).toEqual(["email-otp", "sms-otp"]);
requests[0]!.resolve(Response.json({ id: "email-challenge" }));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(field("challengeId").value).toBe("");
requests[1]!.resolve(Response.json({ id: "sms-challenge" }));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(field("challengeId").value).toBe("sms-challenge");
choose("totp");
expect(field("challengeId").value).toBe("");
expect(requests).toHaveLength(2);
field("otpCode").value = "123456";
form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
expect(field("code").value).toBe("123456");
} finally {
await window.happyDOM.close();
}
});
test("MFA radios preserve allowed methods and restore continuation without reviving expired challenges", async () => {
const window = new Window({ url: "https://sso.example.test/mfa" });
try {
window.sessionStorage.setItem(
"wrnexus.auth.mfa",
JSON.stringify({
mfaToken: "synthetic-test-challenge",
mfaExpiresAt: Date.now() + 300_000,
requires: { mfa: ["totp"] },
}),
);
window.document.body.innerHTML = `<section data-auth-mfa-challenge data-mfa-initial-method="totp">
<form data-auth-mfa-form>
<input type="hidden" name="mfaToken">
<input type="hidden" name="challengeId">
<div data-auth-mfa-method><label><input type="radio" name="method" value="totp">Authenticator app</label><label><input type="radio" name="method" value="email-otp">Email</label></div>
<input name="code"><p data-auth-mfa-status></p><button type="submit">Verify</button>
</form><a data-auth-mfa-restart hidden href="/login">Back to sign in</a>
</section>`;
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
(window as any).WRNexusAuth.mount(window.document);
const form = window.document.querySelector("form")!;
const token = form.querySelector<HTMLInputElement>('[name="mfaToken"]')!;
expect(token.value).toBe("synthetic-test-challenge");
expect(form.querySelector<HTMLInputElement>('input[value="email-otp"]')!.disabled).toBe(true);
token.value = ""; // Hydration reapplies the component's initial empty prop.
form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
expect(token.value).toBe("synthetic-test-challenge");
form.dispatchEvent(
new window.CustomEvent("wrn:error", { bubbles: true, detail: { code: "mfa-invalid" } }),
);
expect(token.value).toBe("synthetic-test-challenge");
form.dispatchEvent(
new window.CustomEvent("wrn:error", {
bubbles: true,
detail: { code: "mfa-transaction-expired" },
}),
);
// Validation finalizers may restore button.disabled after dispatching error.
form.querySelector("button")!.disabled = false;
expect(form.hidden).toBe(true);
expect(
window.document.querySelector<HTMLElement>("[data-auth-mfa-challenge]")!.dataset.mfaExpired,
).toBe("true");
expect(window.sessionStorage.getItem("wrnexus.auth.mfa")).toBeNull();
expect(window.document.querySelector<HTMLElement>("[data-auth-mfa-restart]")!.hidden).toBe(
false,
);
const retry = new window.Event("submit", { bubbles: true, cancelable: true });
form.dispatchEvent(retry);
expect(retry.defaultPrevented).toBe(true);
expect(token.value).toBe("");
} finally {
await window.happyDOM.close();
}
});
@@ -0,0 +1,48 @@
import { expect, test, spyOn } from "bun:test";
import { readFileSync } from "node:fs";
import { Window } from "happy-dom";
test("passkey sign-in follows the server handoff, including cross-app destinations", async () => {
for (const redirect of [
"https://account.example.test/?sso_code=synthetic-code",
"/signed-in",
"javascript:alert(1)",
]) {
const window = new Window({ url: "https://sso.example.test/login" });
const assign = spyOn(window.location, "assign").mockImplementation(() => {});
try {
window.document.body.innerHTML = `<div data-auth-passkey><button>Sign in</button><p data-auth-status></p></div>`;
Object.defineProperty(window, "PublicKeyCredential", { value: class {} });
Object.defineProperty(window.navigator, "credentials", {
value: {
get: async () => ({
id: "synthetic-credential",
type: "public-key",
rawId: new Uint8Array([1]),
response: {
clientDataJSON: new Uint8Array([1]),
authenticatorData: new Uint8Array([2]),
signature: new Uint8Array([3]),
},
}),
},
});
window.fetch = (async (url: string) =>
Response.json(
url.endsWith("/options")
? { ok: true, key: "test-key", options: { challenge: "AQID" } }
: { ok: true, redirect },
)) as any;
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
await (window as any).WRNexusAuth.authenticatePasskey(window.document.querySelector("div"));
if (redirect.startsWith("javascript:")) {
expect(assign).not.toHaveBeenCalled();
} else {
expect(assign).toHaveBeenCalledWith(new URL(redirect, window.location.href).href);
}
} finally {
assign.mockRestore();
await window.happyDOM.close();
}
}
});
+103
View File
@@ -0,0 +1,103 @@
import { expect, test } from "bun:test";
import { createContext } from "@wrnexus/core";
import { createAuthEngine } from "../src/engine.ts";
import { createAuthHttpHandlers } from "../src/http/index.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import { getVerificationUser, VERIFICATION_SESSION_COOKIE } from "../src/verification-session.ts";
import type { AuthDeliveryMessage } from "../src/types.ts";
test("onSignUp issues a persistent verification-only cookie and registration receives a trusted link origin", async () => {
const store = new MemoryAuthStore();
const messages: AuthDeliveryMessage[] = [];
let hookCalls = 0;
const options = {
store,
secret: "signup-session-test-secret-longer-than-32-characters",
requireVerifiedEmail: true,
onSignUp() {
hookCalls++;
return { verificationSession: true, redirectTo: "/verify-email" };
},
delivery: {
async send(message: AuthDeliveryMessage) {
messages.push(message);
},
},
};
const engine = createAuthEngine(options);
const url = new URL("https://sso.example.test/api/auth/register");
const ctx = createContext(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
displayName: "Signup Test",
email: "signup@example.test",
password: "StrongPassword123!",
consent: true,
baseUrl: "https://attacker.example",
}),
}),
url,
);
const response = await createAuthHttpHandlers({ engine }).register(ctx);
expect(response.status).toBe(303);
expect(response.headers.get("location")).toBe("https://sso.example.test/verify-email");
expect(hookCalls).toBe(1);
expect(messages[0]!.url).toBe(
`https://sso.example.test/verify-email?token=${messages[0]!.token}`,
);
expect(messages[0]!.url).not.toContain("attacker");
const setCookie = ctx.cookies
.headers()
.filter((value) => value.startsWith(`${VERIFICATION_SESSION_COOKIE}=`))
.at(-1)!;
expect(setCookie).toContain("HttpOnly");
expect(setCookie).toContain("Secure");
expect(setCookie).toContain("SameSite=Lax");
const cookie = setCookie.split(";")[0]!;
const restartedEngine = createAuthEngine(options);
const nextUrl = new URL("https://sso.example.test/api/auth/verification/request");
const next = createContext(
new Request(nextUrl, {
method: "POST",
headers: { cookie, "content-type": "application/json" },
body: JSON.stringify({ type: "email", identifier: "attacker@example.test" }),
}),
nextUrl,
);
const pending = await getVerificationUser(next, restartedEngine);
expect(pending?.id).toBe(messages[0]!.user.id);
expect(next.user).toBeUndefined();
expect(
(await createAuthHttpHandlers({ engine: restartedEngine }).requestVerification(next)).status,
).toBe(200);
expect(messages[1]!.destination).toBe("signup@example.test");
const token = decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1));
expect(await restartedEngine.validateSession(token)).toBeUndefined();
expect((await restartedEngine.verifyEmail(token)).ok).toBe(false);
expect((await restartedEngine.verifyEmail(messages[1]!.token!)).ok).toBe(true);
expect(await getVerificationUser(next, restartedEngine)).toBeUndefined();
});
test("verification sessions reject expiry, tampering, wrong purpose, and revoked tokens", async () => {
let time = 100_000;
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "signup-session-test-secret-longer-than-32-characters",
clock: { now: () => time },
});
const user = (
await engine.register({ email: "expiry@example.test", password: "StrongPassword123!" })
).user!;
const token = await engine.createVerificationSession(user.id);
expect(await engine.validateVerificationSession(`${token}x`)).toBeUndefined();
expect(
await engine.validateVerificationSession(await engine.issueToken(user.id, "verify-email")),
).toBeUndefined();
time += 30 * 60_000 + 1;
expect(await engine.validateVerificationSession(token)).toBeUndefined();
const renewed = await engine.createVerificationSession(user.id);
await engine.consumeToken(renewed, "verification-session");
expect(await engine.validateVerificationSession(renewed)).toBeUndefined();
});
+22
View File
@@ -0,0 +1,22 @@
import { expect, test } from "bun:test";
import type { Db } from "@wrnexus/db";
import { SqlAuthStore } from "../src/stores/sql.ts";
test("auth store qualifies tables without changing parameter values", async () => {
let query = "";
let values: unknown[] = [];
const db = {
driver: { dialect: "postgres" },
one: async (sql: string, params: unknown[]) => {
query = sql;
values = params;
return undefined;
},
} as unknown as Db;
const store = new SqlAuthStore(db, { schema: "sso", tablePrefix: "" });
await store.findUserById("wrn_auth_users");
expect(query).toContain('FROM "sso"."users"');
expect(query).toContain("$1");
expect(values).toEqual(["wrn_auth_users"]);
expect(() => new SqlAuthStore(db, { schema: 'sso";DROP TABLE users' })).toThrow();
});
+12 -1
View File
@@ -313,7 +313,18 @@ export async function runDbCommand(
break;
}
case "rollback": {
const name = await rollback(db, migrationsDir);
let name: string | null;
try {
name = await rollback(db, migrationsDir);
} catch (error) {
// A migration with no down section is refused rather than half-undone.
// That is an operator decision to make, not a stack trace to read.
const message = error instanceof Error ? error.message : String(error);
if (!message.startsWith("WRN-DB-MIGRATION-IRREVERSIBLE")) throw error;
console.error(`✗ ${message.replace("WRN-DB-MIGRATION-IRREVERSIBLE: ", "")}`);
process.exitCode = 1;
break;
}
console.log(name ? `✓ Rolled back ${name}${label}` : `Nothing to roll back${label}.`);
break;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.8.22",
"version": "0.8.27",
"type": "module",
"main": "src/index.ts",
"exports": {
+54 -11
View File
@@ -344,10 +344,16 @@ function renderAttrs(
exprRefsState(expression, reactive.runtimeStateNames) &&
dynamicExpressions
) {
const marker = exprRefsState(expression, reactive.stateNames)
? ` data-wrn-bind-${bindIndex++}="${attrEscape(JSON.stringify([attr.name, attr.value]))}"`
: "";
if (isHtmlBooleanAttribute(attr.name)) {
dynamicExpressions.push(`\${(${expression}) ? ${JSON.stringify(` ${attr.name}`)} : ""}`);
return `\x00WRNEACH${dynamicExpressions.length - 1}\x00${marker}`;
}
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
const marker = JSON.stringify([attr.name, attr.value]);
return ` ${attr.name}="${sentinel}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
return ` ${attr.name}="${sentinel}"${marker}`;
}
const initial = reactiveAttrValue(attr.value, reactive);
if (initial === null) return base;
@@ -411,15 +417,17 @@ function substituteReactiveText(
dynamicExpressions?: string[],
): string {
const text = substituteTMarkers(raw);
if (!reactive || reactive.stateNames.size === 0) return text;
if (!reactive) return text;
return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
const expr = inner.trim();
if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole;
const clientReactive = exprRefsState(expr, reactive.stateNames);
if (expr.startsWith("t:")) return whole;
if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) {
dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`);
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
return `<span data-text="${attrEscape(expr)}">${sentinel}</span>`;
return clientReactive ? `<span data-text="${attrEscape(expr)}">${sentinel}</span>` : sentinel;
}
if (!clientReactive) return whole;
let value: unknown;
try {
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
@@ -503,6 +511,19 @@ function renderLoopBody(node: ViewNode, locals: string[] = []): string {
return escLit(` ${name}`);
}
// Native boolean attributes inside #if/#each must use presence semantics,
// just like attributes on the page root. disabled="false" still disables
// an HTML input. Component props retain their serialized boolean value.
if (
!componentTag &&
!node.attrs.some((a) => a.name === "data-component") &&
isHtmlBooleanAttribute(name)
) {
const expression = wholeAttributeExpression(attr.value);
if (expression) return "${(" + expression + ") ? " + JSON.stringify(` ${name}`) + ' : ""}';
if (attr.value === "false") return "";
}
return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`);
})
.join("");
@@ -654,6 +675,19 @@ function renderNode(
// templateEscape, swapped for its real `${…}` code after escaping.
if (node.type === "each" || node.type === "if") {
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
const conditions =
node.type === "each"
? [node.list]
: node.branches.flatMap((branch) => (branch.cond ? [branch.cond] : []));
// Server-only loader values are not serialized into browser state. Their
// controls must not be re-evaluated by a surrounding component's hydration.
if (
reactive &&
conditions.some((condition) => exprRefsState(condition, reactive.runtimeStateNames)) &&
!conditions.some((condition) => exprRefsState(condition, reactive.stateNames))
) {
return `\x00WRNEACH${loops.length - 1}\x00`;
}
const definition =
node.type === "each"
? {
@@ -878,7 +912,7 @@ function renderNestedComponentInvocation(
const compiledValue = wholeExpression
? `\${__wrnProp(${ctx.resolveExpr(wholeExpression)})}`
: compileAttrValue(attr.value, ctx);
: compileAttrValue(attr.value, ctx, attr.name === "pattern");
const rendered = ` ${attr.name}="${compiledValue}"`;
@@ -1603,7 +1637,7 @@ function generateInner(ast: PageAst): string {
];
const runtimeStateNames = runtimeComputedNames(orderedStates, orderedComputed, runtimeRoots);
const reactive: PageReactive | null =
reactiveNames.length > 0
reactiveNames.length > 0 || runtimeStateNames.size > 0
? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope }
: null;
const loops: string[] = [];
@@ -2220,7 +2254,7 @@ function compileText(raw: string, ctx: CompCtx): string {
}
/** Compile an attribute value; `{expr}` is baked server-side (loop vars stay literal). */
function compileAttrValue(raw: string, ctx: CompCtx): string {
function compileAttrValue(raw: string, ctx: CompCtx, regexPattern = false): string {
if (!raw.includes("{")) return escLit(attrEscape(raw));
let out = "";
let last = 0;
@@ -2229,7 +2263,9 @@ function compileAttrValue(raw: string, ctx: CompCtx): string {
while ((m = INTERP_RE.exec(raw))) {
out += escLit(attrEscape(raw.slice(last, m.index)));
const expr = m[1]!.trim();
if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
if (regexPattern && /^\d+(?:,\d*)?$/.test(expr)) {
out += escLit(attrEscape(m[0])); // Regex quantifiers are not template expressions.
} else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
out += escLit(`{${expr}}`); // hydrated per-item by the list renderer
} else {
out += `\${__wrnAttr(${ctx.resolveExpr(expr)})}`;
@@ -2594,7 +2630,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
const compiledValue =
isExplicitComponentMount && wholeExpression
? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}`
: compileAttrValue(a.value, elementContext);
: compileAttrValue(a.value, elementContext, a.name === "pattern");
/*
* A constraint attribute whose value is only known at render time (a
@@ -3175,7 +3211,7 @@ function __wrnProp(v: unknown): string {
function renderPageComponentAttr(
attr: Attr,
dynamicExpressions: string[],
_reactive: PageReactive | null,
reactive: PageReactive | null,
): string {
if (attr.event) {
return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
@@ -3194,6 +3230,13 @@ function renderPageComponentAttr(
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
if (
reactive &&
exprRefsState(expression, reactive.runtimeStateNames) &&
!exprRefsState(expression, reactive.stateNames)
) {
return ` ${attr.name}="${marker}"`;
}
// The mount disappears during SSR. Preserve every dynamic component prop
// as a parent-owned binding; server-only expressions simply fail closed in
// the browser, while page state can continue driving the mounted child.
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test";
import { parse, generate } from "../src/index.ts";
test("native boolean attributes inside conditional and loop bodies use presence semantics", async () => {
const source = `page Example {
load server account { return {} }
view {
{#if account.visible}<input required disabled="{!account.edit}" />{/if}
{#each account.rows as row}<input type="checkbox" checked="{row.checked}" disabled="false" />{/each}
}
}`;
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(generate(parse(source)));
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
const html = await mod.default({
account: { visible: true, edit: true, rows: [{ checked: false }, { checked: true }] },
});
expect(html).not.toContain("disabled");
expect(html).not.toContain('checked="false"');
expect(html.match(/ checked/g)).toHaveLength(1);
expect(html).toContain("required");
const denied = await mod.default({ account: { visible: true, edit: false, rows: [] } });
expect(denied).toContain(" disabled");
});
@@ -0,0 +1,22 @@
import { test, expect } from "bun:test";
import { parse, generate } from "../src/index.ts";
test("HTML and component pattern attributes preserve regex quantifiers", () => {
for (const tag of ["input", "Input"]) {
for (const pattern of ["[0-9]{6}", "[a-z]{2,8}", "[A-Z]{1,}"]) {
const code = generate(parse(`page Example { view { <${tag} pattern="${pattern}" /> } }`));
expect(code).toContain(pattern);
expect(code).not.toContain("__wrnAttr(6)");
expect(code).not.toContain("__wrnAttr(2,8)");
}
}
});
test("dynamic pattern props still compile as expressions", () => {
const code = generate(
parse(
'component Example { props { pattern: string = "" } view { <input pattern="{pattern}" /> } }',
),
);
expect(code).toContain("__wrnAttr(pattern)");
});
@@ -0,0 +1,47 @@
import { expect, test } from "bun:test";
import { generate, parse } from "../src/index.ts";
test("server-only loader data renders native input attributes without client state", async () => {
const code = generate(
parse(`import UserSelect from "./UserSelect.wrn"
page Profile {
load server account { return {} }
view {
<h1>{account.name}</h1>
<UserSelect value="{account.name}" options="{account.items}" />
{#if account.readonly}<p>Read only</p>{:else}<p>Editable</p>{/if}
{#each account.items as item}<strong>{item}</strong>{/each}
<input value="{account.name}" readonly="{account.readonly}" />
<select><option value="light" selected="{account.theme === 'light'}">Light</option><option value="dark" selected="{account.theme === 'dark'}">Dark</option></select>
<div data-component="ProfileShell" identity="{account.identity}"></div>
}
}`),
);
const javascript = new Bun.Transpiler({ loader: "ts" }).transformSync(code);
const page = await import(
`data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`
);
const html = await page.default({
account: {
items: ["Example record"],
name: 'A "<User>"',
readonly: false,
theme: "dark",
identity: { name: "Alice" },
},
});
expect(html).toContain('value="A &quot;&lt;User&gt;&quot;"');
expect(html).not.toContain(" readonly");
expect(html).toContain('<option value="light">Light</option>');
expect(html).toContain('<option value="dark" selected>Dark</option>');
expect(html).toContain('identity="{&quot;name&quot;:&quot;Alice&quot;}"');
expect(html).not.toContain("data-wrn-bind");
expect(html).not.toContain("data-wrn-prop-bind");
expect(html).not.toContain("{account.");
expect(html).toContain("<h1>A &quot;&lt;User&gt;&quot;</h1>");
expect(html).toContain("<p>Editable</p>");
expect(html).toContain("<strong>Example record</strong>");
expect(html).not.toContain("data-wrn-if");
expect(html).not.toContain("data-wrn-each");
expect(html).not.toContain("data-text");
});
+22 -2
View File
@@ -231,7 +231,18 @@ export async function migrate(
return applyMigrations(db, loadMigrations(dir), options);
}
/** Roll back the most recently applied migration. Returns its name, or null. */
/**
* Roll back the most recently applied migration. Returns its name, or null when
* nothing has been applied.
*
* Refuses a migration that changed the schema but defines no `-- +down` section.
* Removing its tracking row would leave the change in place and unrecorded, and
* the next `migrate` would replay statements such as `ALTER TABLE ... ADD COLUMN`
* that cannot run twice, stranding the database between the two states.
*
* A migration whose `up` is empty — a placeholder, or comments only — has
* nothing to undo, so it rolls back by dropping its tracking row alone.
*/
export async function rollback(
db: Db,
dir: string,
@@ -241,12 +252,21 @@ export async function rollback(
const applied = await appliedMigrations(db);
const last = applied[applied.length - 1];
if (!last) return null;
const migration = loadMigrations(dir).find((m) => m.name === last);
const reversible =
!migration || !hasExecutableSql(migration.up) || hasExecutableSql(migration.down);
if (!reversible)
throw new Error(
`WRN-DB-MIGRATION-IRREVERSIBLE: ${last} changes the schema but defines no "-- +down" section, ` +
"so rolling it back would drop its record while leaving the change applied. " +
"Add a `-- +down` section to that migration, or write a new migration that reverses it.",
);
// Checked before the lock so a dry run reports the same refusal.
if (options.dryRun) return last;
const release =
options.lock === false
? undefined
: await acquireMigrationLock(db, options.lockTimeoutMs ?? 300_000);
const migration = loadMigrations(dir).find((m) => m.name === last);
try {
throwIfAborted(options.signal);
await db.tx(async (tx) => {
+61
View File
@@ -145,6 +145,67 @@ test("an empty migration set does not touch the database", async () => {
expect(await applyMigrations(db, [])).toEqual([]);
});
test("rollback refuses a schema change that defines no down section", async () => {
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-nodown-"));
writeFileSync(join(dir, "0001_add.sql"), "-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY);");
const db = createDb(sqlite());
expect(await migrate(db, dir)).toEqual(["0001_add"]);
// Rolling this back would drop the record and leave the table, and the next
// migrate would replay a CREATE that cannot run twice.
await expect(rollback(db, dir)).rejects.toThrow(/WRN-DB-MIGRATION-IRREVERSIBLE/);
// The refusal leaves the record alone, so the database stays consistent.
expect(await appliedMigrations(db)).toEqual(["0001_add"]);
expect((await status(db, dir))[0]!.applied).toBe(true);
db.close();
});
test("a dry run reports the same refusal instead of promising a rollback", async () => {
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-dry-"));
writeFileSync(join(dir, "0001_add.sql"), "-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY);");
const db = createDb(sqlite());
await migrate(db, dir);
await expect(rollback(db, dir, { dryRun: true })).rejects.toThrow(
/WRN-DB-MIGRATION-IRREVERSIBLE/,
);
db.close();
});
test("rollback still undoes a migration that defines a down section", async () => {
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-down-"));
writeFileSync(
join(dir, "0001_add.sql"),
"-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY);\n-- +down\nDROP TABLE t;",
);
const db = createDb(sqlite());
await migrate(db, dir);
expect(await rollback(db, dir)).toBe("0001_add");
expect(await appliedMigrations(db)).toEqual([]);
const remaining = await db.all<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name='t'",
);
expect(remaining.length).toBe(0);
db.close();
});
test("a migration whose up is only comments has nothing to undo and rolls back", async () => {
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-noop-"));
writeFileSync(
join(dir, "0001_placeholder.sql"),
"-- +up\n-- Nothing yet.\n-- +down\n-- Nothing to undo.",
);
const db = createDb(sqlite());
await migrate(db, dir);
expect(await rollback(db, dir)).toBe("0001_placeholder");
expect(await appliedMigrations(db)).toEqual([]);
db.close();
});
test("comment-only migrations are recorded without executing empty SQL", async () => {
const db = createDb(sqlite());
const migrations = [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.8.56",
"version": "0.8.59",
"type": "module",
"main": "src/index.ts",
"exports": {
+2
View File
@@ -654,6 +654,8 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
port,
hostname,
development: mode === "development",
// Keep the socket alive long enough to return the configured proxy timeout.
idleTimeout: Math.min(255, Math.max(35, Math.ceil((limits.timeoutMs ?? 30_000) / 1000) + 5)),
maxRequestBodySize: maxBodyBytes,
async fetch(req, srv) {
const url = new URL(req.url);
+2
View File
@@ -634,6 +634,8 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
port,
hostname,
development: mode === "development",
// Cold page compilation can exceed Bun's default connection idle window.
idleTimeout: 35,
maxRequestBodySize: 10 * 1024 * 1024,
...(opts.tls ? { tls: opts.tls } : {}),
fetch(request, server) {
+20 -6
View File
@@ -1746,7 +1746,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
// component defaults creates the real child boundary; the bridged prop
// binding synchronizes the live value when the branch is instantiated.
for (const [key, value] of Object.entries(props)) {
if (/^\{[\s\S]*\}$/.test(value) && !/^\{t:/.test(value)) delete props[key];
if (/^\{[\s\S]*\}$/.test(value) && !/^\{t:/.test(value)) {
// SSR object props also start/end with braces. Preserve serialized
// JSON; only unresolved expressions should fall back to defaults.
try {
JSON.parse(value);
} catch {
delete props[key];
}
}
}
if (normalizedName === "languageswitcher" && deps.i18n) {
props.locales ??= JSON.stringify(
@@ -2614,8 +2622,9 @@ function extractSlots(inner: string): { named: Record<string, string>; def: stri
const named: Record<string, string> = {};
if (!inner.includes("data-slot=")) return { named, def: inner };
const SLOT_MOUNT_RE =
/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?\bdata-slot="([A-Za-z0-9_-]+)"[^>]*?)(\/?)>/;
// Only direct children belong to this mount. Descendant named slots belong
// to their own component (for example a splitter inside a shell's slot).
const SLOT_MOUNT_RE = /<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?)(\/?)>/;
let def = "";
let i = 0;
for (;;) {
@@ -2626,11 +2635,16 @@ function extractSlots(inner: string): { named: Record<string, string>; def: stri
}
const tagStart = i + m.index;
def += inner.slice(i, tagStart);
const [open, tag, , name, selfClose] = m;
const [open, tag, attributes, selfClose] = m;
const name = /\bdata-slot="([A-Za-z0-9_-]+)"/.exec(attributes!)?.[1];
const openEnd = tagStart + open.length;
const { inner: content, end } =
selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(inner, tag!, openEnd);
named[name!] = content;
selfClose === "/" ||
/^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i.test(tag!)
? { inner: "", end: openEnd }
: readElementBody(inner, tag!, openEnd);
if (name) named[name] = content;
else def += inner.slice(tagStart, end);
i = end;
}
return { named, def };
@@ -0,0 +1,60 @@
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, basename } from "node:path";
import { buildRouter } from "@wrnexus/router";
import { renderComponent } from "../../test/src/index.ts";
import { createHandlers } from "../src/runtime.ts";
test("large timezone select responds through the complete page renderer", async () => {
const root = mkdtempSync(join(tmpdir(), "wrn-large-select-"));
try {
mkdirSync(join(root, "pages"));
mkdirSync(join(root, "components"));
writeFileSync(join(root, "pages/index.ts"), "");
for (const name of ["AdvancedSelect", "SelectStyles", "Shell"])
writeFileSync(
join(root, `components/${name}.wrn`),
`component ${name} { view { <div></div> } }`,
);
const select = await renderComponent(
readFileSync(new URL("../../ui/components/AdvancedSelect.wrn", import.meta.url), "utf8"),
{
name: "timezone",
value: "UTC",
searchResultLimit: 40,
options: Intl.supportedValuesOf("timeZone").map((value) => ({ value, label: value })),
},
);
const handlers = createHandlers({
mode: "development",
hmr: false,
router: buildRouter(root),
loadModule: async (file) =>
basename(file) === "AdvancedSelect.wrn"
? { render: () => select }
: basename(file) === "SelectStyles.wrn"
? { render: () => "" }
: basename(file) === "Shell.wrn"
? { render: () => "<main><slot></slot></main>" }
: {
default: () =>
'<div data-component="Shell">' +
'<div data-component="AdvancedSelect"></div>'.repeat(4) +
"</div>",
},
getMiddleware: async () => [],
assets: { serve: async () => null },
});
const response = await handlers.fetch(new Request("http://example.test/"), {
upgrade: () => false,
});
expect(response?.status).toBe(200);
const html = await response!.text();
expect(html).toContain("Asia/Kolkata");
// Four full lists previously exceeded 11 MB, including hydration metadata.
expect(html.length).toBeLessThan(6_000_000);
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 15000);
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test";
import { fillSlots } from "../src/runtime.ts";
test("an outer default slot preserves a nested component's named slots", () => {
const child =
'<div data-component="LayoutSplitter"><div data-slot="start">Inbox</div><div data-slot="end">Message</div></div>';
const shell = fillSlots("<main><slot></slot></main>", child, true);
expect(shell).toContain(child);
const panes = fillSlots(
'<section><slot name="start"></slot><slot name="end"></slot></section>',
'<div data-slot="start">Inbox</div><div data-slot="end">Message</div>',
true,
);
expect(panes).toContain("Inbox</wrn-slot>");
expect(panes).toContain("Message</wrn-slot>");
});
test("void children do not swallow following direct named slots", () => {
const html = fillSlots(
'<slot name="header"></slot><slot></slot>',
'Text<input><div data-slot="header">Heading</div><p>Body</p>',
);
expect(html).toBe("HeadingText<input><p>Body</p>");
});
@@ -0,0 +1,47 @@
import { expect, test } from "bun:test";
import { createProductionHandlers } from "../src/prod.ts";
test("component resolution preserves JSON user/permission props and drops unresolved bindings", async () => {
let received: Record<string, string> = {};
const handlers = createProductionHandlers(
{
pages: [
{
raw: "/",
mod: {
default: () =>
'<div data-component="account-shell" identity="{&quot;displayName&quot;:&quot;Alice&quot;}" permissions="{&quot;read&quot;:true,&quot;update&quot;:false}" preferences="{&quot;theme&quot;:&quot;dark&quot;}" pending="{account.pending}"><p>Account content</p></div>',
},
},
],
api: [],
realtime: [],
middleware: [],
layouts: [],
components: [
{
name: "AccountShell",
mod: {
render: (props: Record<string, string>) => {
received = props;
const identity = JSON.parse(props.identity ?? "{}");
const permissions = JSON.parse(props.permissions ?? "{}");
return permissions.read
? "<main>" + identity.displayName + "<slot /></main>"
: "<main>Denied</main>";
},
},
},
],
},
{},
);
const response = await handlers.fetch(new Request("http://localhost/"), { upgrade: () => false });
expect(response?.status).toBe(200);
const html = await response!.text();
expect(html).toContain("Alice");
expect(html).toContain("Account content");
expect(JSON.parse(received.permissions!)).toEqual({ read: true, update: false });
expect(JSON.parse(received.preferences!)).toEqual({ theme: "dark" });
expect(received.pending).toBeUndefined();
});
@@ -0,0 +1,26 @@
import { expect, test } from "bun:test";
import { documentDiagnostics, virtualTypeScriptDocument } from "../src/index.ts";
test("editor diagnostics accept translated PageHeader attributes without hiding real errors", () => {
const document = {
uri: "file:///contact.wrn",
text: `page Contact {
view {
<div data-component="PageHeader" eyebrow="{t:contact.eyebrow}" title="{t:contact.title}" description="{t:contact.description}"></div>
<p title="{t:contact.title} {missingValue}"></p>
}
}`,
};
const diagnostics = documentDiagnostics(document).filter(
(item) => item.source === "wrnexus-types",
);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]?.message).toContain("missingValue");
expect(diagnostics[0]?.range.start.line).toBe(3);
expect(virtualTypeScriptDocument(document).text).not.toContain("void (t:");
expect(
documentDiagnostics({ ...document, text: document.text.replace("{missingValue}", "") }).filter(
(item) => item.source === "wrnexus-types",
),
).toEqual([]);
});
+77
View File
@@ -5,3 +5,80 @@ Gateway-neutral, webhook-authoritative payments for WrNexus. Amounts are integer
Built-in adapters: offline `sandbox`, Stripe, Razorpay, and PayPal. Applications may add adapters with `defineGateway`. Capabilities are explicit, unsupported operations are refused, signed webhooks are mandatory in production, and package-owned migrations provision the append-only event ledger.
Webhook events are deduplicated by the provider's event ID and reduced in provider occurrence/sequence order. Captured and refund states are monotonic, so a delayed retry of an older event cannot regress a terminal payment.
Configure any number of named gateway accounts in `wrnexus.config.ts`; keep credentials in environment variables:
```ts
const payment = {
default: "razorpayIndia",
gateways: {
razorpayIndia: {
provider: "razorpay",
keyId: process.env.RAZORPAY_KEY_ID!,
keySecret: process.env.RAZORPAY_KEY_SECRET!,
webhookSecret: process.env.RAZORPAY_WEBHOOK_SECRET ?? "",
},
stripeGlobal: {
provider: "stripe",
secretKey: process.env.STRIPE_SECRET_KEY!,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
},
},
} satisfies PaymentConfig;
```
At runtime, call `configurePaymentFromConfig(config.payment, databasePaymentStore())` once. Pass a configured gateway name to `initializePayment` to override the default for a specific checkout.
## Package APIs
When a modern `gateways` configuration is present, the plugin contributes these routes:
- `GET /api/payments/providers` — client-safe names, capabilities, currencies, and countries
- `GET|POST /api/payments/intents` — list the authenticated subject's payments or initialize one
- `GET /api/payments/intents/[id]` — status and client-safe checkout configuration
- `POST /api/payments/intents/[id]/sync`
- `POST /api/payments/intents/[id]/capture`
- `POST /api/payments/intents/[id]/cancel`
- `POST /api/payments/intents/[id]/refund`
- `GET /api/payments/methods`
- `POST /api/payments/reconcile`
- `POST /api/payments/webhook/:gateway`
Provider secrets are held only by server adapters and are never returned by provider, intent, or status APIs. Request-body subject values are ignored. Configure authentication and authorization in the same payment block:
```ts
payment: {
default: "razorpayIndia",
gateways: { /* named providers */ },
api: {
subject: (ctx) => ctx.session?.userId,
authorize: async (ctx, action, payment) => {
if (["capture", "refund", "reconcile"].includes(action))
return await isBillingAdmin(ctx)
return !payment || payment.subject === ctx.session?.userId
},
// Recommended for catalog purchases: derive price and metadata server-side.
initialize: async (ctx, body, subject) => resolveCatalogPurchase(body, subject),
},
}
```
Administrative actions deny by default. Set `api: false` to expose only the signed webhook endpoint. Use CSRF protection and rate limiting in the application's normal middleware.
## Pages, themes, and languages
The package exposes `PaymentOverviewPage`, `PaymentDetailsPage`, `PaymentCheckoutPage`, and `PaymentMethodsPage`, plus the smaller payment components. These use framework theme variables, inherit light/dark mode and accent colors, and collapse to a single-column mobile layout.
All visible copy is supplied through component props. Applications can pass their normal i18n messages without the package forcing a locale or duplicating the application's language switcher:
```wrn
<PaymentMethodsPage
methods={payments.methods}
title={t.paymentMethodsTitle}
description={t.paymentMethodsDescription}
emptyLabel={t.noPaymentMethods}
/>
```
Provider-hosted card fields remain on the provider's origin. Applications should never collect or pass raw card numbers, CVVs, or expiry values through these APIs.
+5 -1
View File
@@ -1,5 +1,9 @@
component PayNow {
props { checkoutUrl: string label?: string = "Pay now" disabled?: boolean = false }
props {
checkoutUrl: string
label?: string = "Pay now"
disabled?: boolean = false
}
view {
<a class="wrn-payment-button" href={checkoutUrl} aria-disabled={disabled}>
{label}
@@ -0,0 +1,16 @@
import PaymentSheet from "./PaymentSheet.wrn"
component PaymentCheckoutPage {
props {
checkoutUrl: string
gateway: string
title?: string = "Secure checkout"
description?: string = "Complete payment through the selected secure provider."
securityLabel?: string = "Your payment credentials are handled by the payment provider."
}
view {
<section class="wrn-payment-page wrn-payment-checkout-page">
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{gateway}</span><h1>{title}</h1><p>{description}</p></div></header>
<div class="wrn-payment-panel"><PaymentSheet checkoutUrl={checkoutUrl} gateway={gateway} /><p class="wrn-payment-security">{securityLabel}</p></div>
</section>
}
}
@@ -0,0 +1,22 @@
import PaymentStatus from "./PaymentStatus.wrn"
component PaymentDetailsPage {
props {
payment: object
title?: string = "Payment details"
amountLabel?: string = "Amount"
providerLabel?: string = "Provider"
referenceLabel?: string = "Reference"
createdLabel?: string = "Created"
}
view {
<section class="wrn-payment-page">
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{title}</span><h1>{title}</h1></div><PaymentStatus status={payment.status} /></header>
<dl class="wrn-payment-details">
<div><dt>{amountLabel}</dt><dd>{payment.currency} {payment.amount}</dd></div>
<div><dt>{providerLabel}</dt><dd>{payment.gateway}</dd></div>
<div><dt>{referenceLabel}</dt><dd>{payment.id}</dd></div>
<div><dt>{createdLabel}</dt><dd>{payment.createdAt}</dd></div>
</dl>
</section>
}
}
@@ -0,0 +1,15 @@
import PaymentMethodList from "./PaymentMethodList.wrn"
component PaymentMethodsPage {
props {
methods: array = []
title?: string = "Payment methods"
description?: string = "Manage provider-vaulted payment methods."
emptyLabel?: string = "No saved payment methods."
}
view {
<section class="wrn-payment-page">
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{title}</span><h1>{title}</h1><p>{description}</p></div></header>
<div class="wrn-payment-panel">{#if methods.length}<PaymentMethodList methods={methods} />{:else}<div class="wrn-payment-empty">{emptyLabel}</div>{/if}</div>
</section>
}
}
@@ -0,0 +1,21 @@
import PaymentHistory from "./PaymentHistory.wrn"
import PaymentSummary from "./PaymentSummary.wrn"
component PaymentOverviewPage {
props {
payments: array = []
captured: number = 0
refunded: number = 0
net: number = 0
currency: string = "USD"
title?: string = "Payments"
description?: string = "Review payment activity, statuses, and totals."
emptyLabel?: string = "No payments yet."
}
view {
<section class="wrn-payment-page">
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{title}</span><h1>{title}</h1><p>{description}</p></div></header>
<PaymentSummary captured={captured} refunded={refunded} net={net} currency={currency} />
<div class="wrn-payment-panel"><h2>{title}</h2>{#if payments.length}<PaymentHistory payments={payments} />{:else}<div class="wrn-payment-empty">{emptyLabel}</div>{/if}</div>
</section>
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
component PaymentSheet {
props { checkoutUrl: string gateway: string }
props {
checkoutUrl: string
gateway: string
}
view { <section class="wrn-payment-sheet" data-gateway={gateway}><p>Secure payment is completed on the payment provider.</p><a href={checkoutUrl}>Continue securely</a></section> }
}
@@ -1,4 +1,7 @@
component PaymentStatus {
props { status: string label?: string = "Payment status" }
props {
status: string
label?: string = "Payment status"
}
view { <div class="wrn-payment-status" role="status"><strong>{label}</strong><span>{status}</span></div> }
}
@@ -1,4 +1,9 @@
component PaymentSummary {
props { captured: number = 0 refunded: number = 0 net: number = 0 currency: string }
view { <dl><dt>Captured</dt><dd>{currency} {captured}</dd><dt>Refunded</dt><dd>{currency} {refunded}</dd><dt>Net</dt><dd>{currency} {net}</dd></dl> }
props {
captured: number = 0
refunded: number = 0
net: number = 0
currency: string
}
view { <dl class="wrn-payment-summary"><div><dt>Captured</dt><dd>{currency} {captured}</dd></div><div><dt>Refunded</dt><dd>{currency} {refunded}</dd></div><div><dt>Net</dt><dd>{currency} {net}</dd></div></dl> }
}
+6 -1
View File
@@ -1,4 +1,9 @@
component RefundButton {
props { paymentId: string refundable: number partial?: boolean = false action?: string = "/api/payments/refund" }
props {
paymentId: string
refundable: number
partial?: boolean = false
action?: string = "/api/payments/refund"
}
view { <form method="post" action={action}><input type="hidden" name="paymentId" value={paymentId} /><label>Reason<input name="reason" required /></label>{#if partial}<label>Amount<input name="amount" type="number" min="1" max={refundable} /></label>{/if}<button type="submit">Refund</button></form> }
}
+154
View File
@@ -0,0 +1,154 @@
.wrn-payment-page {
color: var(--color-text, var(--wrn-color-text, #f5f7f6));
display: grid;
gap: 1.25rem;
width: 100%;
}
.wrn-payment-page__header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.wrn-payment-page__header h1 {
font-size: clamp(1.75rem, 4vw, 2.75rem);
letter-spacing: -0.04em;
margin: 0.35rem 0;
}
.wrn-payment-page__header p {
color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
margin: 0;
max-width: 42rem;
}
.wrn-payment-page__eyebrow {
color: var(--color-primary, var(--wrn-color-primary, #10b981));
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.wrn-payment-panel,
.wrn-payment-details,
.wrn-payment-summary {
background: var(--color-surface, var(--wrn-color-surface, #111412));
border: 1px solid var(--color-border, var(--wrn-color-border, #29302c));
border-radius: var(--radius-lg, 1rem);
padding: 1.25rem;
}
.wrn-payment-summary {
display: grid;
gap: 1rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.wrn-payment-summary div,
.wrn-payment-details div {
display: grid;
gap: 0.35rem;
}
.wrn-payment-summary dt,
.wrn-payment-details dt {
color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
font-size: 0.75rem;
text-transform: uppercase;
}
.wrn-payment-summary dd,
.wrn-payment-details dd {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
}
.wrn-payment-details {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
}
.wrn-payment-history,
.wrn-payment-methods {
display: grid;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.wrn-payment-history li,
.wrn-payment-methods li {
align-items: center;
border-bottom: 1px solid var(--color-border, var(--wrn-color-border, #29302c));
display: flex;
justify-content: space-between;
padding: 0.85rem 0;
}
.wrn-payment-history li:last-child,
.wrn-payment-methods li:last-child {
border-bottom: 0;
}
.wrn-payment-status {
align-items: center;
background: color-mix(
in srgb,
var(--color-primary, var(--wrn-color-primary, #10b981)) 12%,
transparent
);
border: 1px solid
color-mix(in srgb, var(--color-primary, var(--wrn-color-primary, #10b981)) 32%, transparent);
border-radius: 999px;
display: inline-flex;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
}
.wrn-payment-status strong {
font-size: 0.72rem;
}
.wrn-payment-status span {
text-transform: capitalize;
}
.wrn-payment-sheet {
display: grid;
gap: 1rem;
}
.wrn-payment-sheet a,
.wrn-payment-button {
align-items: center;
background: var(--color-primary, var(--wrn-color-primary, #10b981));
border-radius: var(--radius-md, 0.7rem);
color: var(--color-on-primary, #fff);
display: inline-flex;
font-weight: 700;
justify-content: center;
min-height: 2.75rem;
padding: 0.65rem 1rem;
text-decoration: none;
}
.wrn-payment-button[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.5;
pointer-events: none;
}
.wrn-payment-empty {
color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
padding: 2rem;
text-align: center;
}
.wrn-payment-security {
color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
font-size: 0.8rem;
}
.wrn-payment-panel h2 {
font-size: 1rem;
margin: 0 0 0.75rem;
}
@media (max-width: 640px) {
.wrn-payment-page__header {
display: grid;
}
.wrn-payment-summary,
.wrn-payment-details {
grid-template-columns: 1fr;
}
.wrn-payment-panel,
.wrn-payment-details,
.wrn-payment-summary {
padding: 1rem;
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/payment",
"version": "0.8.1",
"version": "0.8.6",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,6 +13,7 @@
".": "./src/index.ts",
"./adapters": "./src/adapters.ts",
"./plugin": "./src/plugin.ts",
"./runtime-api": "./src/runtime-api.ts",
"./runtime-webhook": "./src/runtime-webhook.ts"
},
"dependencies": {
+17
View File
@@ -343,6 +343,23 @@ export interface RazorpayConfig {
webhookSecret: string;
theme?: { color?: string };
}
export interface RazorpayCheckoutResult {
orderId: string;
paymentId: string;
signature: string;
}
/** Verify the signed result returned by Razorpay Standard Checkout. */
export async function verifyRazorpayCheckoutSignature(
result: RazorpayCheckoutResult,
keySecret: string,
) {
if (!result.orderId || !result.paymentId || !result.signature || !keySecret) return false;
return equal(
result.signature.toLowerCase(),
await hmac(`${result.orderId}|${result.paymentId}`, keySecret),
);
}
export function razorpayGateway(config: RazorpayConfig): PaymentGateway<RazorpayConfig> {
const auth = `Basic ${btoa(`${config.keyId}:${config.keySecret}`)}`;
const request = (path: string, init: RequestInit = {}) =>
+118
View File
@@ -0,0 +1,118 @@
import {
paypalGateway,
razorpayGateway,
sandboxGateway,
stripeGateway,
type PayPalConfig,
type RazorpayConfig,
type SandboxOptions,
type StripeConfig,
} from "./adapters.ts";
import { configurePayment, type PaymentOptions, type PaymentService } from "./core.ts";
import type { PaymentGateway, PaymentStore } from "./types.ts";
import type { PaymentApiConfig } from "./http.ts";
export type ConfiguredPaymentGateway =
| ({ provider: "sandbox" } & SandboxOptions)
| ({ provider: "stripe" } & StripeConfig)
| ({ provider: "razorpay" } & RazorpayConfig)
| ({ provider: "paypal" } & PayPalConfig);
export interface PaymentConfig {
/** Name of the gateway used when an operation does not select one explicitly. */
default: string;
/** Any number of named gateway accounts. Names are persisted with payment intents. */
gateways: Record<string, ConfiguredPaymentGateway | false>;
environment?: "development" | "test" | "production";
api?: PaymentApiConfig | false;
/** Defaults to the package database store. Override for custom persistence or tests. */
store?: PaymentStore;
}
function required(name: string, fields: Record<string, unknown>) {
for (const [field, value] of Object.entries(fields))
if (typeof value !== "string" || !value.trim())
throw new Error(`WRN-PAYMENT-CONFIG: gateway '${name}' requires ${field}`);
}
export function validatePaymentConfig(config: PaymentConfig) {
if (!config?.default?.trim()) throw new Error("WRN-PAYMENT-CONFIG: payment.default is required");
if (!config.gateways || typeof config.gateways !== "object")
throw new Error("WRN-PAYMENT-CONFIG: payment.gateways is required");
for (const [name, value] of Object.entries(config.gateways)) {
if (value === false) continue;
if (!name.trim()) throw new Error("WRN-PAYMENT-CONFIG: gateway name is required");
if (value.provider === "stripe")
required(name, {
secretKey: value.secretKey,
publishableKey: value.publishableKey,
webhookSecret: value.webhookSecret,
});
if (value.provider === "razorpay")
required(name, { keyId: value.keyId, keySecret: value.keySecret });
if (value.provider === "razorpay" && config.environment === "production")
required(name, { webhookSecret: value.webhookSecret });
if (value.provider === "paypal")
required(name, {
clientId: value.clientId,
clientSecret: value.clientSecret,
webhookId: value.webhookId,
});
}
if (!config.gateways[config.default])
throw new Error(
`WRN-PAYMENT-CONFIG: default gateway '${config.default}' is not enabled or configured`,
);
}
function namedGateway(name: string, gateway: PaymentGateway): PaymentGateway {
return Object.freeze({ ...gateway, id: name, provider: gateway.provider ?? gateway.id });
}
export function paymentOptionsFromConfig(
config: PaymentConfig,
store: PaymentStore,
): PaymentOptions {
validatePaymentConfig(config);
const adapters: PaymentGateway[] = [];
const webhookSecrets: Record<string, string> = {};
for (const [name, value] of Object.entries(config.gateways)) {
if (value === false) continue;
if (!name.trim()) throw new Error("WRN-PAYMENT-CONFIG: gateway name is required");
let adapter: PaymentGateway;
switch (value.provider) {
case "sandbox":
adapter = sandboxGateway(value);
break;
case "stripe":
adapter = stripeGateway(value);
break;
case "razorpay":
adapter = razorpayGateway(value);
break;
case "paypal":
adapter = paypalGateway(value);
break;
default:
throw new Error(`WRN-PAYMENT-CONFIG: unsupported provider '${(value as any).provider}'`);
}
adapters.push(namedGateway(name, adapter));
if ("webhookSecret" in value && value.webhookSecret) webhookSecrets[name] = value.webhookSecret;
if (value.provider === "paypal" && value.webhookId) webhookSecrets[name] = value.webhookId;
}
return {
store,
adapters,
default: config.default,
webhookSecrets,
environment: config.environment,
};
}
export function configurePaymentFromConfig(
config: PaymentConfig,
store: PaymentStore,
): PaymentService {
return configurePayment(paymentOptionsFromConfig(config, store));
}
+7
View File
@@ -416,6 +416,13 @@ export function definePayment(options: PaymentOptions) {
reconcilePayments,
paymentWebhookHandler,
capabilitiesOf: (id: string) => adapterFor(id)?.capabilities,
providers: () =>
[...adapters.values()].map((adapter) => ({
id: adapter.id,
provider: adapter.provider ?? adapter.id,
capabilities: adapter.capabilities,
supports: adapter.supports,
})),
};
}
+96
View File
@@ -0,0 +1,96 @@
import type { InitializePaymentInput, PaymentService } from "./core.ts";
import type { PaymentIntent } from "./types.ts";
export type PaymentApiAction =
| "list"
| "read"
| "initialize"
| "sync"
| "cancel"
| "capture"
| "refund"
| "methods"
| "reconcile";
export interface PaymentApiContext {
req: Request;
params?: Record<string, string>;
user?: { id?: string };
session?: { userId?: string; subject?: string };
}
export interface PaymentApiConfig {
enabled?: boolean;
/** Resolve the authenticated subject on the server. Request-body subject values are ignored. */
subject?(context: PaymentApiContext): string | undefined | Promise<string | undefined>;
/** Additional authorization. Administrative actions deny by default. */
authorize?(
context: PaymentApiContext,
action: PaymentApiAction,
payment?: PaymentIntent,
): boolean | Promise<boolean>;
/** Replace client input with authoritative catalog/order data before creating an intent. */
initialize?(
context: PaymentApiContext,
body: Record<string, unknown>,
subject: string,
): Omit<InitializePaymentInput, "subject"> | Promise<Omit<InitializePaymentInput, "subject">>;
}
const HTTP = Symbol.for("@wrnexus/payment:http:v1");
type PaymentHttpGlobal = typeof globalThis & { [HTTP]?: PaymentApiConfig };
export function configurePaymentApi(config: PaymentApiConfig = {}) {
(globalThis as PaymentHttpGlobal)[HTTP] = config;
}
export function getPaymentApiConfig(): PaymentApiConfig {
return (globalThis as PaymentHttpGlobal)[HTTP] ?? {};
}
export async function paymentApiSubject(context: PaymentApiContext) {
const config = getPaymentApiConfig();
const resolved = await config.subject?.(context);
return resolved?.trim() || context.user?.id?.trim() || context.session?.userId?.trim() || "";
}
export async function paymentApiAllowed(
context: PaymentApiContext,
action: PaymentApiAction,
payment?: PaymentIntent,
) {
const config = getPaymentApiConfig();
if (config.enabled === false) return false;
if (config.authorize) return Boolean(await config.authorize(context, action, payment));
const subject = await paymentApiSubject(context);
if (!subject) return false;
if (["capture", "refund", "reconcile"].includes(action)) return false;
return !payment || payment.subject === subject;
}
export function publicPayment(payment: PaymentIntent, status: string) {
return {
id: payment.id,
amount: payment.amount,
currency: payment.currency,
gateway: payment.gateway,
status,
createdAt: payment.createdAt,
checkoutUrl: payment.checkoutUrl,
client: payment.client,
metadata: payment.metadata,
};
}
export async function ensureOwnedPayment(
service: PaymentService,
context: PaymentApiContext,
id: string,
action: PaymentApiAction,
) {
const payment = await service.getPayment(id);
if (!payment) return { response: Response.json({ error: "payment not found" }, { status: 404 }) };
if (!(await paymentApiAllowed(context, action, payment)))
return { response: Response.json({ error: "payment access denied" }, { status: 403 }) };
return { payment };
}
+2
View File
@@ -3,5 +3,7 @@ export * from "./store.ts";
export * from "./database.ts";
export * from "./core.ts";
export * from "./adapters.ts";
export * from "./config.ts";
export * from "./http.ts";
export * from "./contract.ts";
export { paymentPlugin } from "./plugin.ts";
+53 -9
View File
@@ -1,10 +1,25 @@
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import { configurePaymentFromConfig, validatePaymentConfig, type PaymentConfig } from "./config.ts";
import { configurePaymentApi } from "./http.ts";
import { databasePaymentStore } from "./database.ts";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const extension = basename(dirname(fileURLToPath(import.meta.url))) === "dist" ? ".js" : ".ts";
const webhookEntry = join(dirname(fileURLToPath(import.meta.url)), `runtime-webhook${extension}`);
const apiEntry = join(dirname(fileURLToPath(import.meta.url)), `runtime-api${extension}`);
const apiRoutes = [
"/api/payments/providers",
"/api/payments/intents",
"/api/payments/intents/[id]",
"/api/payments/intents/[id]/sync",
"/api/payments/intents/[id]/capture",
"/api/payments/intents/[id]/cancel",
"/api/payments/intents/[id]/refund",
"/api/payments/methods",
"/api/payments/reconcile",
];
const migration = `-- +up
CREATE TABLE IF NOT EXISTS wrn_payment_intent (id TEXT PRIMARY KEY, subject TEXT NOT NULL, gateway TEXT NOT NULL, gateway_ref TEXT NOT NULL, amount BIGINT NOT NULL, currency TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, created_at BIGINT NOT NULL, checkout_url TEXT, client_json TEXT NOT NULL, metadata_json TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS wrn_payment_intent_subject ON wrn_payment_intent(subject, created_at);
@@ -31,25 +46,54 @@ export function paymentPlugin() {
const key = "@wrnexus/payment:enabled";
return definePlugin({
name: "@wrnexus/payment",
version: "0.8.1",
version: "0.8.5",
componentDirs: [join(root, "components")],
styleSources: [
{
id: "payment-components",
entry: join(root, "components", "payment.css"),
source: join(root, "components"),
order: "normal",
},
],
routeEntries(context) {
return context.metadata.get(key)
? [{ kind: "api" as const, path: "/api/payments/webhook/:gateway", entry: webhookEntry }]
: [];
if (!context.metadata.get(key)) return [];
const apiEnabled = context.metadata.get("@wrnexus/payment:api") !== false;
return [
{ kind: "api" as const, path: "/api/payments/webhook/:gateway", entry: webhookEntry },
...(apiEnabled
? apiRoutes.map((path) => ({ kind: "api" as const, path, entry: apiEntry }))
: []),
];
},
configure(config, context) {
const value = config.payment as
{ default?: string; gateways?: Record<string, unknown>; sandbox?: boolean } | undefined;
const value = config.payment as PaymentConfig | undefined;
context.metadata.set(key, Boolean(value));
if (!value) return;
const api = value.api === false ? { enabled: false } : (value.api ?? {});
context.metadata.set(
"@wrnexus/payment:api",
Boolean(value.gateways) && api.enabled !== false,
);
configurePaymentApi(api);
if (value.gateways) {
validatePaymentConfig(value);
configurePaymentFromConfig(value, value.store ?? databasePaymentStore());
}
if (!value.default) throw new Error("WRN-PAYMENT-CONFIG: payment.default is required");
if (!value.gateways?.[value.default] && value.default !== "sandbox")
const legacySandbox = value.default === "sandbox" && !value.gateways;
if (!value.gateways?.[value.default] && !legacySandbox)
throw new Error(
`WRN-PAYMENT-CONFIG: default gateway '${value.default}' has no configuration`,
);
if (process.env.NODE_ENV === "production" && value.sandbox)
throw new Error("WRN-PAYMENT-CONFIG: sandbox cannot be enabled in production");
if (
process.env.NODE_ENV === "production" &&
(legacySandbox ||
Object.values(value.gateways ?? {}).some(
(gateway) => gateway && gateway.provider === "sandbox",
))
)
throw new Error("WRN-PAYMENT-CONFIG: sandbox gateways cannot be enabled in production");
},
migrations(context: PluginContext) {
return context.metadata.get(key)
+117
View File
@@ -0,0 +1,117 @@
import { getPaymentService } from "./core.ts";
import {
ensureOwnedPayment,
getPaymentApiConfig,
paymentApiAllowed,
paymentApiSubject,
publicPayment,
type PaymentApiContext,
} from "./http.ts";
const json = (value: unknown, status = 200) => Response.json(value, { status });
const bodyOf = async (req: Request) =>
(await req.json().catch(() => ({}))) as Record<string, unknown>;
const idOf = (ctx: PaymentApiContext) => ctx.params?.id?.trim() ?? "";
const resultResponse = (result: any, success = 200) =>
result.ok
? json(result.value, success)
: json({ error: result.reason, fault: result.fault }, 422);
export async function GET(ctx: PaymentApiContext) {
const service = getPaymentService();
const path = new URL(ctx.req.url).pathname;
if (path.endsWith("/providers")) {
if (getPaymentApiConfig().enabled === false)
return json({ error: "payment API disabled" }, 404);
return json({ providers: service.providers() });
}
if (path.endsWith("/methods")) {
if (!(await paymentApiAllowed(ctx, "methods")))
return json({ error: "payment access denied" }, 403);
return json({ methods: await service.paymentMethods(await paymentApiSubject(ctx)) });
}
if (idOf(ctx)) {
const owned = await ensureOwnedPayment(service, ctx, idOf(ctx), "read");
if (owned.response) return owned.response;
return json(publicPayment(owned.payment!, await service.checkPayment(owned.payment!.id)));
}
if (!(await paymentApiAllowed(ctx, "list"))) return json({ error: "payment access denied" }, 403);
const subject = await paymentApiSubject(ctx);
const rows = await service.listPayments({ subject });
return json({
payments: await Promise.all(
rows.map(async (payment) => publicPayment(payment, await service.checkPayment(payment.id))),
),
});
}
export async function POST(ctx: PaymentApiContext) {
const service = getPaymentService();
const path = new URL(ctx.req.url).pathname;
const body = await bodyOf(ctx.req);
if (path.endsWith("/intents")) {
if (!(await paymentApiAllowed(ctx, "initialize")))
return json({ error: "payment access denied" }, 403);
const subject = await paymentApiSubject(ctx);
const resolver = getPaymentApiConfig().initialize;
const input = resolver
? await resolver(ctx, body, subject)
: {
amount: Number(body.amount),
currency: String(body.currency ?? "").toUpperCase(),
gateway: typeof body.gateway === "string" ? body.gateway : undefined,
country: typeof body.country === "string" ? body.country : undefined,
capture: body.capture === "manual" ? ("manual" as const) : ("automatic" as const),
idempotencyKey: String(
body.idempotencyKey ?? ctx.req.headers.get("idempotency-key") ?? "",
),
returnUrl: typeof body.returnUrl === "string" ? body.returnUrl : undefined,
metadata:
body.metadata && typeof body.metadata === "object"
? (body.metadata as Record<string, string>)
: undefined,
};
const result = await service.initializePayment({ ...input, subject });
if (!result.ok) return resultResponse(result, 201);
return json(publicPayment(result.value, await service.checkPayment(result.value.id)), 201);
}
if (path.endsWith("/reconcile")) {
if (!(await paymentApiAllowed(ctx, "reconcile")))
return json({ error: "payment access denied" }, 403);
return json({ discrepancies: await service.reconcilePayments() });
}
const id = idOf(ctx);
if (!id) return json({ error: "payment id is required" }, 400);
const action = path.endsWith("/sync")
? "sync"
: path.endsWith("/capture")
? "capture"
: path.endsWith("/cancel")
? "cancel"
: path.endsWith("/refund")
? "refund"
: undefined;
if (!action) return json({ error: "payment operation not found" }, 404);
const owned = await ensureOwnedPayment(service, ctx, id, action);
if (owned.response) return owned.response;
if (action === "sync") return resultResponse(await service.syncPayment(id));
if (action === "capture")
return resultResponse(
await service.capturePayment(
id,
body.amount === undefined
? undefined
: { amount: Number(body.amount), currency: owned.payment!.currency },
),
);
if (action === "cancel")
return resultResponse(await service.cancelPayment(id, String(body.reason ?? "")));
return resultResponse(
await service.refundPayment({
id,
amount: body.amount === undefined ? undefined : Number(body.amount),
reason: String(body.reason ?? ""),
idempotencyKey: String(body.idempotencyKey ?? ctx.req.headers.get("idempotency-key") ?? ""),
}),
);
}
+2
View File
@@ -82,6 +82,8 @@ export interface WebhookEvent {
export interface PaymentGateway<TConfig = unknown> {
readonly id: string;
/** Built-in provider type when the id is an application-defined gateway alias. */
readonly provider?: string;
readonly config?: TConfig;
readonly capabilities: GatewayCapabilities;
readonly supports: GatewaySupport;
@@ -3,6 +3,7 @@ import {
exerciseGatewayContract,
paypalGateway,
razorpayGateway,
verifyRazorpayCheckoutSignature,
sandboxGateway,
signSandboxWebhook,
stripeGateway,
@@ -180,6 +181,22 @@ test("shared executable contract drives Razorpay through its HTTP adapter", asyn
expect(report).toEqual({ ok: true, issues: [] });
});
test("Razorpay checkout signatures are verified without trusting browser data", async () => {
const secret = "checkout-secret";
const orderId = "order_1";
const paymentId = "pay_1";
const signed = await signature(`${orderId}|${paymentId}`, secret);
expect(
await verifyRazorpayCheckoutSignature({ orderId, paymentId, signature: signed }, secret),
).toBe(true);
expect(
await verifyRazorpayCheckoutSignature(
{ orderId, paymentId: "pay_tampered", signature: signed },
secret,
),
).toBe(false);
});
test("shared executable contract drives PayPal through its HTTP adapter", async () => {
globalThis.fetch = (async (value, init) => {
const url = String(value),
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from "bun:test";
import { memoryPaymentStore } from "../src/store.ts";
import { paymentOptionsFromConfig } from "../src/config.ts";
test("configuration creates any number of named provider gateways", () => {
const options = paymentOptionsFromConfig(
{
default: "india",
environment: "test",
gateways: {
india: {
provider: "razorpay",
keyId: "rzp_test",
keySecret: "secret",
webhookSecret: "razorpay-hook",
},
global: {
provider: "stripe",
secretKey: "sk_test",
publishableKey: "pk_test",
webhookSecret: "stripe-hook",
},
disabled: false,
},
},
memoryPaymentStore(),
);
expect(options.default).toBe("india");
expect(options.adapters.map((adapter) => adapter.id)).toEqual(["india", "global"]);
expect(options.adapters.map((adapter) => adapter.provider)).toEqual(["razorpay", "stripe"]);
expect(options.webhookSecrets).toEqual({ india: "razorpay-hook", global: "stripe-hook" });
});
test("configuration rejects a disabled or missing default gateway", () => {
expect(() =>
paymentOptionsFromConfig(
{ default: "missing", gateways: { sandbox: { provider: "sandbox" } } },
memoryPaymentStore(),
),
).toThrow("default gateway 'missing' is not enabled or configured");
});
+71
View File
@@ -0,0 +1,71 @@
import { expect, test } from "bun:test";
import { configurePaymentFromConfig } from "../src/config.ts";
import { configurePaymentApi } from "../src/http.ts";
import { GET, POST } from "../src/runtime-api.ts";
import { memoryPaymentStore } from "../src/store.ts";
function request(path: string, body?: unknown) {
return new Request(`https://app.test${path}`, {
method: body ? "POST" : "GET",
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
}
test("package APIs use server subjects and never expose gateway secrets", async () => {
configurePaymentFromConfig(
{
default: "testGateway",
environment: "test",
gateways: { testGateway: { provider: "sandbox", secret: "server-only" } },
},
memoryPaymentStore(),
);
configurePaymentApi({ subject: (ctx) => ctx.user?.id });
const providers = await GET({ req: request("/api/payments/providers") });
const providerBody = await providers.text();
expect(providers.status).toBe(200);
expect(providerBody).toContain("testGateway");
expect(providerBody).toContain('"provider":"sandbox"');
expect(providerBody).not.toContain("server-only");
const denied = await POST({
req: request("/api/payments/intents", {
subject: "attacker-selected",
amount: 500,
currency: "USD",
idempotencyKey: "anonymous",
}),
});
expect(denied.status).toBe(403);
const created = await POST({
req: request("/api/payments/intents", {
subject: "attacker-selected",
amount: 500,
currency: "USD",
idempotencyKey: "owned",
}),
user: { id: "user-1" },
});
expect(created.status).toBe(201);
const payment = await created.json();
expect(payment.id).toStartWith("pay_");
expect(JSON.stringify(payment)).not.toContain("server-only");
const listed = await GET({
req: request("/api/payments/intents"),
user: { id: "user-1" },
});
expect((await listed.json()).payments).toHaveLength(1);
});
test("administrative payment APIs deny by default", async () => {
configurePaymentApi({ subject: () => "user-1" });
const response = await POST({
req: request("/api/payments/reconcile", {}),
user: { id: "user-1" },
});
expect(response.status).toBe(403);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/syntax",
"version": "0.8.18",
"version": "0.8.19",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -3
View File
@@ -768,9 +768,7 @@ function expandStructuredStateDeclarations(lines, unit) {
}
function formatStructuredPropDeclaration(value, unit, depth, printWidth) {
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([\[{][\s\S]*)$/.exec(
value,
);
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([[{][\s\S]*)$/.exec(value);
if (!match) return null;
try {
+2
View File
@@ -3,3 +3,5 @@
Static type checking for `.wrn` declarations, props, state, outputs, functions, stores, and generated virtual TypeScript files.
The package is compiler tooling rather than a browser UI package, so its public kit consists of programmatic typecheck helpers and diagnostics.
View attribute `{t:key}` segments are translation markers, not TypeScript expressions. Ordinary expressions in the same attribute remain typechecked. Inline event bindings accept statement lists, with `event.currentTarget` typed as the bound HTML/SVG element. Nullable DOM queries still require normal guards.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/typecheck",
"version": "0.8.16",
"version": "0.8.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+11 -3
View File
@@ -377,6 +377,9 @@ export function virtualTypeScriptModule(
// misspelled state/function name cannot silently become `undefined` at
// runtime. Each/handler locals mirror the browser evaluator's bindings.
let bindingIndex = 0;
append(
"type __WrnEventElement<Tag extends string> = Tag extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[Tag] : Tag extends keyof SVGElementTagNameMap ? SVGElementTagNameMap[Tag] : HTMLElement;",
);
const reservedProps = new Set(
ast.props.map((prop) => prop.name).filter((name) => !safeBindingName(name)),
);
@@ -386,7 +389,12 @@ export function virtualTypeScriptModule(
for (const attr of node.attrs) {
const expressions = attr.event
? [attr.value]
: extractInterpolations(attr.value).map((segment) => segment.expression);
: extractInterpolations(attr.value)
// Translation markers are resolved by i18n, not evaluated as JavaScript.
// Filter individual segments so mixed translated/dynamic attributes
// still typecheck their ordinary expressions.
.filter((segment) => !/^t:[^{}]+$/.test(segment.expression))
.map((segment) => segment.expression);
for (const expression of expressions) {
if (!expression.trim()) continue;
const declarations = [
@@ -394,12 +402,12 @@ export function virtualTypeScriptModule(
...(attr.event
? [
"declare const payload: any;",
"declare const event: Event & { target: HTMLElement };",
`declare const event: Event & { target: HTMLElement; currentTarget: __WrnEventElement<${JSON.stringify(node.tag)}> };`,
]
: []),
].join(" ");
append(
`namespace __wrn_view_${bindingIndex++} { ${declarations} void (${safeViewExpression(expression, reservedProps)}); }`,
`namespace __wrn_view_${bindingIndex++} { ${declarations} ${attr.event ? `void (() => { ${safeViewExpression(expression, reservedProps)} });` : `void (${safeViewExpression(expression, reservedProps)});`} }`,
attr.value,
);
}
@@ -0,0 +1,59 @@
import { expect, test } from "bun:test";
import { checkWrnSource, virtualTypeScriptModule } from "../src/index.ts";
test("translation markers in native and component attributes are not TypeScript expressions", () => {
const source = `page Contact {
state count = 1
view {
<div data-component="PageHeader" eyebrow="{t:contact.eyebrow}" title="{t:contact.title}" description="{t:contact.description}"></div>
<input aria-label="{t:contact.form.name}" placeholder='{t:contact.form.namePlaceholder}' />
<div data-success-message="{t:contact.form.successMessage}" title="{t:contact.title} — {count}">{t:contact.title}</div>
<span t:aria-label="contact.form.label"></span>
}
}`;
expect(checkWrnSource(source)).toEqual([]);
const virtual = virtualTypeScriptModule(source).code;
expect(virtual).not.toContain("void (t:");
expect(virtual).toContain("void (count)");
});
test("mixed translation bindings retain genuine expression diagnostics", () => {
const source = `page Contact {
view {
<div title="{t:contact.title} — {missingValue}"></div>
<div title="{true ? missingBranch : 'fallback'}"></div>
<div data-value="{{ t: missingProperty }}"></div>
}
}`;
const diagnostics = checkWrnSource(source);
for (const name of ["missingValue", "missingBranch", "missingProperty"]) {
expect(
diagnostics.some((item) => item.code === "WRN-TYPE-2304" && item.message.includes(name)),
).toBe(true);
}
expect(diagnostics.some((item) => item.message.includes("name 't'"))).toBe(false);
});
test("the translation exemption does not apply to event handlers", () => {
const diagnostics = checkWrnSource(
`page Contact { view { <button @click="t:contact.title"></button> } }`,
);
expect(
diagnostics.some((item) => item.code === "WRN-TYPE-2304" && item.message.includes("contact")),
).toBe(true);
});
test("form event statement lists have the correct currentTarget type", () => {
const source = `page Contact {
view {
<form @wrn:success="event.currentTarget.reset(); toast.success('Sent')"></form>
<input @input="toast.info(event.currentTarget.value)" />
<button @click="event.currentTarget.disabled = true; toast.info('Working')"></button>
}
}`;
expect(checkWrnSource(source)).toEqual([]);
const invalid = checkWrnSource(
source.replace("event.currentTarget.disabled", "event.currentTarget.notARealProperty"),
);
expect(invalid.some((item) => item.code === "WRN-TYPE-2339")).toBe(true);
});
+7 -7
View File
@@ -119,7 +119,7 @@ Theme-aware, responsive badge component.
Theme-aware, responsive blockquote component.
- Mount: `data-component="Blockquote"`
- Props: `quote: string = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."`, `citation: string = ""`, `citationTitle: string = ""`, `citationUrl: string = ""`, `avatarSrc: string = ""`, `avatarAlt: string = ""`, `size: string = "md"`, `color: string = "primary"`, `align: string = "left"`, `variant: string = "default"`, `quoteMark: boolean = true`, `italic: boolean = true`, `class: string = ""`
- Props: `quote: string = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."`, `citation: string = ""`, `citationTitle: string = ""`, `citationUrl: string = ""`, `avatarSrc: string = ""`, `avatarAlt: string = ""`, `size: string = "md"`, `color: string = "primary"`, `align: string = "left"`, `variant: string = "default"`, `quoteMark: boolean = true`, `italic: boolean = true`, `maxWidth: string = "xl"`, `class: string = ""`
- Slots: `default`
- Outputs: None
@@ -290,7 +290,7 @@ Theme-aware, responsive styled icon component.
Present responsive chronological activity, milestones, or workflow status with rich item metadata.
- Mount: `data-component="Timeline"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `numbered: boolean = false`, `class: string = ""`
- Slots: `default`
- Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
@@ -413,7 +413,7 @@ Theme-aware, responsive file input component.
Theme-aware, responsive input component.
- Mount: `data-component="Input"`
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Input"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `value: string = ""`, `type: string = "text"`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `autocomplete: string = ""`, `inputmode: string = ""`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = ""`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `class: string = ""`
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Input"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `value: string = ""`, `type: string = "text"`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `invalidMessage: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `autocomplete: string = ""`, `inputmode: string = ""`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = ""`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `class: string = ""`
- Slots: None
- Outputs: `input({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `change({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `focus({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `blur({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `invalid({ value: string | number | boolean | null | object; name: string; message: string; sourceEvent: Event })`, `keydown({ key: string; value: string | number | boolean | null | object; name: string; sourceEvent: Event })`, `keyup({ key: string; value: string | number | boolean | null | object; name: string; sourceEvent: Event })`
@@ -827,7 +827,7 @@ Theme-aware, responsive navbar component.
- Mount: `data-component="Navbar"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Primary navigation"`, `topbarLabel: string = "Utility navigation"`, `brand: Record<string, unknown> = {}`, `items: unknown[] = []`, `actions: unknown[] = []`, `active: string = ""`, `sticky: boolean = false`, `floating: boolean = false`, `startFloating: string = "0px"`, `openOnHover: boolean = false`, `maxWidth: string = "full"`, `mobileLabel: string = "Toggle navigation"`, `collapseAt: string = "900px"`, `mobilePanel: string = "content"`, `mobileUtilitiesPosition: string = "header"`, `gutter: string = "md"`, `contentGutter: string = "md"`, `flushMobile: boolean = false`, `translationPrefix: string = ""`, `class: string = ""`
- Slots: `topbar`, `actions`, `utilities`
- Slots: `topbar`, `brand`, `actions`, `utilities`
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ item: string | number | boolean | null | object; value: string | number | boolean; level: string })`, `action({ item: string | number | boolean | null | object; value: string | number | boolean })`
### Pagination
@@ -853,7 +853,7 @@ Theme-aware, responsive scrollspy component.
Theme-aware, responsive sidebar component.
- Mount: `data-component="Sidebar"`
- Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `mobileLabel: string = "Open navigation"`, `drawerTitle: string = "Navigation"`, `class: string = ""`
- Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `mobileLabel: string = "Open navigation"`, `drawerTitle: string = "Navigation"`, `mobile: boolean = true`, `drawerPlacement: string = "left"`, `class: string = ""`
- Slots: `default`, `drawer`
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent: Event })`, `close({ source: string })`, `select({ item: object; value: string; level: number })`
@@ -872,7 +872,7 @@ Switch between related responsive content panels with horizontal or vertical ori
- Mount: `data-component="Tabs"`
- Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mode: string = "client"`, `param: string = "tab"`, `label: string = "Tabs"`, `class: string = ""`
- Slots: `panel-{valueOf(item, index)}`, `default`
- Slots: `default`
- Outputs: `change({ value: string; item: object; index: number })`, `select({ value: string; item: object; index: number })`
## Overlays
@@ -900,7 +900,7 @@ Present responsive modal side or bottom content with focus management, backdrop
Open an accessible anchored menu with keyboard navigation, item selection, actions, and responsive placement.
- Mount: `data-component="Dropdown"`
- Props: `items: unknown[] = []`, `open: boolean = false`, `defaultOpen: boolean = false`, `label: string = "Open menu"`, `icon: string = ""`, `showChevron: boolean = true`, `menuLabel: string = "Dropdown menu"`, `placement: string = "bottom-start"`, `width: string = "md"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `closeOnSelect: boolean = true`, `closeOnOutside: boolean = true`, `disabled: boolean = false`, `emptyLabel: string = "No menu items"`, `class: string = ""`
- Props: `items: unknown[] = []`, `open: boolean = false`, `defaultOpen: boolean = false`, `label: string = "Open menu"`, `ariaLabel: string = ""`, `icon: string = ""`, `showChevron: boolean = true`, `menuLabel: string = "Dropdown menu"`, `placement: string = "bottom-start"`, `width: string = "md"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `closeOnSelect: boolean = true`, `closeOnOutside: boolean = true`, `disabled: boolean = false`, `emptyLabel: string = "No menu items"`, `class: string = ""`
- Slots: `trigger`, `header`, `default`, `footer`
- Outputs: `toggle({ open: boolean; sourceEvent: Event; reason?: object })`, `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `select({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`, `action({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`
+51 -2
View File
@@ -1751,6 +1751,13 @@
"default": "true",
"options": []
},
{
"name": "maxWidth",
"type": "string",
"required": false,
"default": "\"xl\"",
"options": []
},
{
"name": "class",
"type": "string",
@@ -1791,6 +1798,13 @@
"default": "\"\"",
"options": []
},
{
"name": "activeIcon",
"type": "string",
"required": false,
"default": "\"icon-[lucide--file-text]\"",
"options": []
},
{
"name": "separator",
"type": "string",
@@ -5461,6 +5475,13 @@
"default": "\"Open menu\"",
"options": []
},
{
"name": "ariaLabel",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "icon",
"type": "string",
@@ -7044,6 +7065,13 @@
"default": "\"\"",
"options": []
},
{
"name": "invalidMessage",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "inline",
"type": "boolean",
@@ -9282,7 +9310,7 @@
"options": []
}
],
"slots": ["topbar", "actions", "utilities"],
"slots": ["topbar", "brand", "actions", "utilities"],
"outputs": [
{
"name": "toggle",
@@ -11528,6 +11556,20 @@
"default": "\"Navigation\"",
"options": []
},
{
"name": "mobile",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "drawerPlacement",
"type": "string",
"required": false,
"default": "\"left\"",
"options": []
},
{
"name": "class",
"type": "string",
@@ -12727,7 +12769,7 @@
"options": []
}
],
"slots": ["panel-{valueOf(item, index)}", "default"],
"slots": ["default"],
"outputs": [
{
"name": "change",
@@ -13329,6 +13371,13 @@
"default": "\"default\"",
"options": []
},
{
"name": "numbered",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "class",
"type": "string",
+4 -4
View File
@@ -304,7 +304,7 @@ component AdvancedSelect {
aria-label="{selectedOptionsLabel}"
data-show="multiple && selectedTemplate !== 'count' && selectedTemplate !== 'text' && selectedCount() > 0"
>
{#each allOptions() as option}
{#each multiple && selectedTemplate !== 'count' && selectedTemplate !== 'text' ? selectedOptions() : [] as option}
<span data-show="isSelected(option)">
{#if optionTemplate === "icon" && option.icon}<i class="{option.icon}" aria-hidden="true"></i>{/if}
{#if optionTemplate === "avatar" && option.avatar}<img src="{option.avatar}" alt="" />{/if}
@@ -371,11 +371,11 @@ component AdvancedSelect {
role="listbox"
aria-multiselectable="{multiple}"
>
{#each groups as group}
{#each open ? groups : [] as group}
{#if visibleOptions(group.options || []).length}
<div class="wrn-next__option-group" role="group" aria-label="{group.label}">
<div class="wrn-next__group-label">{group.label}</div>
{#each group.options || [] as option}
{#each visibleOptions(group.options || []) as option}
<button
type="button"
class="wrn-next__select-option {isSelected(option) ? 'wrn-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wrn-next__select-option--active' : ''}"
@@ -402,7 +402,7 @@ component AdvancedSelect {
{/if}
{/each}
{#each options as option}
{#each open ? visibleOptions(options) : [] as option}
<button
type="button"
class="wrn-next__select-option {isSelected(option) ? 'wrn-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wrn-next__select-option--active' : ''}"
+8 -2
View File
@@ -150,7 +150,7 @@ open: boolean = false
aria-label='{closeLabel}'
@click='hideDrawer("close-button", event)'
>
<span class="icon-[lucide--x]" aria-hidden="true"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
{/if}
</header>
@@ -397,7 +397,7 @@ open: boolean = false
.wrn-drawer__header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.35rem 1.35rem 1.1rem;
@@ -519,6 +519,12 @@ open: boolean = false
display: none;
}
@media (max-width: 767px) {
.wrn-drawer__header {
padding-block: 0.75rem;
}
}
@media (max-width: 639px) {
.wrn-drawer[data-placement="right"] .wrn-drawer__panel,
.wrn-drawer[data-placement="left"] .wrn-drawer__panel {
+39 -15
View File
@@ -12,10 +12,12 @@ items: unknown[] = []
open: boolean = false
defaultOpen: boolean = false
label: string = "Open menu"
ariaLabel: string = ""
icon: string = ""
showChevron: boolean = true
menuLabel: string = "Dropdown menu"
placement: string = "bottom-start"
mobilePanel: string = "sheet"
width: string = "md"
size: string = "default"
color: string = "primary"
@@ -47,6 +49,11 @@ items: unknown[] = []
visible = false
output.close({ reason: reason, sourceEvent: sourceEvent })
output.toggle({ open: false, reason: reason, sourceEvent: sourceEvent })
if (reason === "escape") {
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
const trigger = root.querySelector(".wrn-dropdown__trigger")
if (trigger) { trigger.focus() }
}
}
client function toggleMenu(sourceEvent) {
@@ -65,14 +72,14 @@ items: unknown[] = []
output.select({
item: item,
itemIndex: itemIndex,
value: item.value || "",
value: item.value == null ? "" : item.value,
sourceEvent: sourceEvent
})
if (item.action) {
output.action({
item: item,
itemIndex: itemIndex,
value: item.value || "",
value: item.value == null ? "" : item.value,
sourceEvent: sourceEvent
})
}
@@ -83,18 +90,24 @@ items: unknown[] = []
client function moveFocus(sourceEvent, direction) {
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
const options = [...root.querySelectorAll("[data-dropdown-item]:not([disabled])")]
if (!options.length) {
return
}
const activeIndex = options.indexOf(document.activeElement)
const nextIndex = (activeIndex + direction + options.length) % options.length
options[nextIndex].focus()
window.setTimeout(function () {
if (!root.isConnected || !isOpen()) { return }
const options = [...root.querySelectorAll('[data-dropdown-item]:not([disabled]):not([aria-disabled="true"])')]
if (!options.length) {
return
}
const activeIndex = options.indexOf(document.activeElement)
const nextIndex = activeIndex < 0 ? (direction > 0 ? 0 : options.length - 1) : (activeIndex + direction + options.length) % options.length
options[nextIndex].focus()
}, 0)
}
client function handleKeydown(sourceEvent) {
if (disabled) { return }
if (sourceEvent.key === "Escape") {
if (!isOpen()) { return }
sourceEvent.preventDefault()
sourceEvent.stopPropagation()
hideMenu("escape", sourceEvent)
} else if (sourceEvent.key === "ArrowDown") {
sourceEvent.preventDefault()
@@ -111,19 +124,26 @@ items: unknown[] = []
} else if (sourceEvent.key === "Home" && isOpen()) {
sourceEvent.preventDefault()
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
const first = root.querySelector("[data-dropdown-item]:not([disabled])")
const first = root.querySelector('[data-dropdown-item]:not([disabled]):not([aria-disabled="true"])')
if (first) {
first.focus()
}
} else if (sourceEvent.key === "End" && isOpen()) {
sourceEvent.preventDefault()
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
const options = [...root.querySelectorAll("[data-dropdown-item]:not([disabled])")]
const options = [...root.querySelectorAll('[data-dropdown-item]:not([disabled]):not([aria-disabled="true"])')]
if (options.length) {
options[options.length - 1].focus()
}
}
}
client function handleFocusout(sourceEvent) {
const root = sourceEvent.currentTarget
if (isOpen() && sourceEvent.relatedTarget && !root.contains(sourceEvent.relatedTarget)) {
hideMenu("focusout", sourceEvent)
}
}
}
view {
@@ -132,18 +152,21 @@ items: unknown[] = []
data-ui-component="Dropdown"
data-open='{open || visible ? "true" : "false"}'
data-placement='{placement}'
data-mobile-panel='{mobilePanel}'
data-width='{width}'
data-size='{size}'
data-color='{color}'
data-variant='{variant}'
class='wrn-dropdown {class}'
@keydown='handleKeydown(event)'
@focusout='handleFocusout(event)'
>
<button
type="button"
class="wrn-dropdown__trigger"
disabled='{disabled}'
aria-haspopup="menu"
aria-label='{ariaLabel || label}'
aria-expanded='{open || visible ? "true" : "false"}'
@click='toggleMenu(event)'
>
@@ -163,7 +186,7 @@ items: unknown[] = []
<button
type="button"
class="wrn-dropdown__dismiss-layer"
data-show='{open || visible}'
hidden='{!(open || visible)}'
aria-label="Close menu"
@click='hideMenu("outside", event)'
></button>
@@ -172,7 +195,7 @@ items: unknown[] = []
<div
class="wrn-dropdown__panel"
data-wrn-anchored="true"
data-show='{open || visible}'
hidden='{!(open || visible)}'
role="menu"
aria-label='{menuLabel}'
>
@@ -194,6 +217,7 @@ items: unknown[] = []
data-danger='{item.danger ? "true" : "false"}'
data-selected='{item.selected || item.checked ? "true" : "false"}'
aria-disabled='{item.disabled ? "true" : "false"}'
tabindex='{item.disabled ? "-1" : "0"}'
class="wrn-dropdown__item"
@click='chooseItem(item, itemIndex, event)'
>
@@ -460,7 +484,7 @@ items: unknown[] = []
border: 0;
border-radius: 0.72rem;
font: inherit;
text-align: left;
text-align: start;
text-decoration: none;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease, transform 150ms ease;
@@ -550,7 +574,7 @@ items: unknown[] = []
}
@media (max-width: 639px) {
.wrn-dropdown__panel {
.wrn-dropdown[data-placement][data-mobile-panel="sheet"] .wrn-dropdown__panel {
position: fixed;
right: 0.75rem;
bottom: 0.75rem;
+8
View File
@@ -475,6 +475,7 @@ component Footer {
}
.wrn-footer__copyright {
grid-column: 2;
margin: 0;
text-align: center;
white-space: nowrap;
@@ -502,7 +503,12 @@ component Footer {
text-underline-offset: 0.2em;
}
.wrn-footer__copyright-left {
grid-column: 1;
}
.wrn-footer__copyright-right {
grid-column: 3;
justify-content: flex-end;
text-align: right;
}
@@ -545,10 +551,12 @@ component Footer {
}
.wrn-footer__copyright-left,
.wrn-footer__copyright-right {
grid-column: 1;
justify-content: center;
text-align: center;
}
.wrn-footer__copyright {
grid-column: 1;
white-space: normal;
}
}
+113 -11
View File
@@ -30,6 +30,8 @@ component Sidebar {
active: string = ""
mobileLabel: string = "Open navigation"
drawerTitle: string = "Navigation"
mobile: boolean = true
drawerPlacement: string = "left"
class: string = ""
}
@@ -60,14 +62,16 @@ component Sidebar {
return Boolean(entry.value) && entry.value === active
}
client function choose(entry, level) {
client function choose(entry, level, sourceEvent) {
if (entry.disabled) {
sourceEvent.preventDefault()
return
}
output.select({ item: entry, value: entry.value || "", level: level })
if (drawerOpen) {
drawerOpen = false
output.close({ source: "select" })
output.toggle({ open: false })
}
}
@@ -91,6 +95,7 @@ component Sidebar {
class='wrn-sidebar {class}'
data-color='{color}'
data-size='{size}'
data-mobile='{mobile ? "true" : "false"}'
>
<button
type="button"
@@ -117,7 +122,8 @@ component Sidebar {
data-active='{isActive(entry)}'
aria-current='{isActive(entry) ? "page" : "false"}'
aria-disabled='{entry.disabled ? "true" : "false"}'
@click='choose(entry, 1)'
tabindex='{entry.disabled ? "-1" : "0"}'
@click='choose(entry, 1, event)'
>
<span
class='wrn-sidebar__icon {entry.icon}'
@@ -137,7 +143,9 @@ component Sidebar {
data-active='{isActive(child)}'
aria-current='{isActive(child) ? "page" : "false"}'
aria-disabled='{child.disabled ? "true" : "false"}'
@click='choose(child, 2)'
data-wrn-roving-item="true"
tabindex='{child.disabled ? "-1" : "0"}'
@click='choose(child, 2, event)'
>
<span
class='wrn-sidebar__icon {child.icon}'
@@ -159,7 +167,10 @@ component Sidebar {
href='{leaf.href || "#"}'
data-active='{isActive(leaf)}'
aria-current='{isActive(leaf) ? "page" : "false"}'
@click='choose(leaf, 3)'
aria-disabled='{leaf.disabled ? "true" : "false"}'
data-wrn-roving-item="true"
tabindex='{leaf.disabled ? "-1" : "0"}'
@click='choose(leaf, 3, event)'
>
<span class="wrn-sidebar__label">{leaf.label}</span>
</a>
@@ -175,15 +186,94 @@ component Sidebar {
<slot />
</nav>
{#if mobile}
<Drawer
open={drawerOpen}
placement="left"
placement={drawerPlacement}
title={drawerTitle}
label={label}
@close="closeDrawer()"
>
<nav class="wrn-sidebar__drawer-nav" aria-label='{label}'>
<ul class="wrn-sidebar__list" data-wrn-roving="vertical">
{#each entryList() as entry}
<li class="wrn-sidebar__entry" data-group='{isGroup(entry)}'>
<p class="wrn-sidebar__heading" data-show="entry.heading">{entry.heading}</p>
<a
class="wrn-sidebar__link"
data-show="!entry.heading"
href='{entry.href || "#"}'
data-wrn-roving-item="true"
data-active='{isActive(entry)}'
aria-current='{isActive(entry) ? "page" : "false"}'
aria-disabled='{entry.disabled ? "true" : "false"}'
tabindex='{entry.disabled ? "-1" : "0"}'
@click='choose(entry, 1, event)'
>
<span
class='wrn-sidebar__icon {entry.icon}'
data-show="entry.icon"
aria-hidden="true"
></span>
<span class="wrn-sidebar__label">{entry.label}</span>
<span class="wrn-sidebar__badge" data-show="entry.badge">{entry.badge}</span>
</a>
<ul class="wrn-sidebar__sublist" data-show="childrenOf(entry).length > 0">
{#each childrenOf(entry) as child}
<li class="wrn-sidebar__entry">
<a
class="wrn-sidebar__link"
href='{child.href || "#"}'
data-active='{isActive(child)}'
aria-current='{isActive(child) ? "page" : "false"}'
aria-disabled='{child.disabled ? "true" : "false"}'
data-wrn-roving-item="true"
tabindex='{child.disabled ? "-1" : "0"}'
@click='choose(child, 2, event)'
>
<span
class='wrn-sidebar__icon {child.icon}'
data-show="child.icon"
aria-hidden="true"
></span>
<span class="wrn-sidebar__label">{child.label}</span>
<span class="wrn-sidebar__badge" data-show="child.badge">{child.badge}</span>
</a>
<ul
class="wrn-sidebar__sublist wrn-sidebar__sublist--level3"
data-show="childrenOf(child).length > 0"
>
{#each childrenOf(child) as leaf}
<li class="wrn-sidebar__entry">
<a
class="wrn-sidebar__link"
href='{leaf.href || "#"}'
data-active='{isActive(leaf)}'
aria-current='{isActive(leaf) ? "page" : "false"}'
aria-disabled='{leaf.disabled ? "true" : "false"}'
data-wrn-roving-item="true"
tabindex='{leaf.disabled ? "-1" : "0"}'
@click='choose(leaf, 3, event)'
>
<span class="wrn-sidebar__label">{leaf.label}</span>
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
</nav>
<slot name="drawer"></slot>
</Drawer>
{/if}
</div>
}
@@ -231,9 +321,21 @@ component Sidebar {
}
.wrn-sidebar__sublist {
margin-left: 0.85rem;
padding-left: 0.5rem;
border-left: 1px solid var(--wrn-color-border);
margin-inline-start: 0.85rem;
padding-inline-start: 0.5rem;
border-inline-start: 1px solid var(--wrn-color-border);
}
.wrn-sidebar__entry[data-group="true"] > .wrn-sidebar__sublist {
margin-inline-start: 0;
padding-inline-start: 0;
border-inline-start: 0;
}
.wrn-sidebar__icon {
width: 1.05rem;
height: 1.05rem;
flex-shrink: 0;
}
.wrn-sidebar__entry[data-group="true"] + .wrn-sidebar__entry {
@@ -286,7 +388,7 @@ component Sidebar {
}
.wrn-sidebar__badge {
margin-left: auto;
margin-inline-start: auto;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
@@ -323,11 +425,11 @@ component Sidebar {
* the focus trap and the scroll lock.
*/
@media (max-width: 767px) {
.wrn-sidebar__launcher {
.wrn-sidebar[data-mobile="true"] > .wrn-sidebar__launcher {
display: inline-flex;
}
.wrn-sidebar__rail {
.wrn-sidebar[data-mobile="true"] > .wrn-sidebar__rail {
display: none;
}
}
+17 -5
View File
@@ -41,7 +41,16 @@ component StrongPassword {
state detailsOpen: boolean = false
state revealed: boolean = false
state nativeError: string = ""
functions {
client function handleInvalid(sourceEvent) {
sourceEvent.preventDefault()
sourceEvent.stopPropagation()
nativeError = validationMessage || (sourceEvent.currentTarget.validity.valueMissing ? "Enter your password." : "Enter a password that meets the requirements.")
sourceEvent.currentTarget.setAttribute("aria-invalid", "true")
}
shared function hasMinimumLength() {
return password.length >= Number(minLength)
}
@@ -126,7 +135,7 @@ component StrongPassword {
view {
<div
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--strong-password wrn-next--strong-password-{presentation} {invalid ? 'wrn-next--invalid' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--strong-password wrn-next--strong-password-{presentation} {invalid || validationMessage || nativeError ? 'wrn-next--invalid' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
data-wrn-strong-password
data-strength="{password ? strengthLevel() : 'empty'}"
data-score="{password ? score() : 0}"
@@ -146,11 +155,11 @@ component StrongPassword {
disabled="{disabled}"
readonly="{readonly}"
required="{required}"
aria-invalid="{invalid ? 'true' : 'false'}"
aria-describedby="{validationMessage ? name + '-validation' : hintText ? name + '-hint' : ''}"
aria-invalid="{invalid || nativeError ? 'true' : 'false'}"
aria-describedby="{(validationMessage || nativeError) ? name + '-validation' : hintText ? name + '-hint' : ''}"
@focus="detailsOpen = true"
@blur="detailsOpen = false"
@input="password = event.target.value; output.input({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() }); output.strength({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() })"
@invalid="handleInvalid(event)" @input="nativeError = ''; password = event.target.value; output.input({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() }); output.strength({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() })"
@change="output.change({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() })"
/>
<button
@@ -231,11 +240,14 @@ component StrongPassword {
</div>
{/if}
<small id="{name}-validation" class="wrn-next__validation" data-error="{name}">{validationMessage}</small>
<small id="{name}-validation" class="wrn-next__validation" data-error="{name}">{(validationMessage || nativeError)}</small>
</div>
}
style {
.wrn-next--strong-password .wrn-next__validation {
color: var(--wrn-color-danger, #ef4444);
}
.wrn-next--strong-password {
position: relative;
display: grid;
+20 -8
View File
@@ -36,7 +36,16 @@ component TogglePassword {
state revealed = visible
state nativeError: string = ""
functions {
client function handleInvalid(sourceEvent) {
sourceEvent.preventDefault()
sourceEvent.stopPropagation()
nativeError = validationMessage || (sourceEvent.currentTarget.validity.valueMissing ? "Enter your password." : "Enter a password that meets the requirements.")
sourceEvent.currentTarget.setAttribute("aria-invalid", "true")
}
shared function toggleVisibility() {
if (disabled || readonly || !toggleable) {
return
@@ -55,7 +64,7 @@ component TogglePassword {
view {
<fieldset
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--toggle-password {invalid ? 'wrn-next--invalid' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--toggle-password {invalid || validationMessage || nativeError ? 'wrn-next--invalid' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
data-wrn-toggle-password
data-visible="{revealed ? 'true' : 'false'}"
>
@@ -78,8 +87,8 @@ component TogglePassword {
disabled="{disabled || field.disabled}"
readonly="{readonly || field.readonly}"
required="{required || field.required}"
aria-invalid="{invalid ? 'true' : 'false'}"
@input="output.input({ name: event.target.name, value: event.target.value, visible: revealed })"
aria-invalid="{invalid || nativeError ? 'true' : 'false'}"
@invalid="handleInvalid(event)" @input="nativeError = ''; output.input({ name: event.target.name, value: event.target.value, visible: revealed })"
@change="output.change({ name: event.target.name, value: event.target.value, visible: revealed })"
/>
{#if toggleable && toggleMode === "button"}
@@ -122,9 +131,9 @@ component TogglePassword {
disabled="{disabled}"
readonly="{readonly}"
required="{required}"
aria-invalid="{invalid ? 'true' : 'false'}"
aria-describedby="{validationMessage ? name + '-validation' : helpText ? name + '-help' : ''}"
@input="output.input({ value: event.target.value, visible: revealed })"
aria-invalid="{invalid || nativeError ? 'true' : 'false'}"
aria-describedby="{(validationMessage || nativeError) ? name + '-validation' : helpText ? name + '-help' : ''}"
@invalid="handleInvalid(event)" @input="nativeError = ''; output.input({ value: event.target.value, visible: revealed })"
@change="output.change({ value: event.target.value, visible: revealed })"
/>
{#if toggleable && toggleMode === "button"}
@@ -153,16 +162,19 @@ component TogglePassword {
<span>{checkboxLabel}</span>
</label>
{/if}
{#if helpText && !validationMessage}<small id="{name}-help">{helpText}</small>{/if}
{#if helpText && !(validationMessage || nativeError)}<small id="{name}-help">{helpText}</small>{/if}
<small
id="{name}-validation"
class="wrn-next__validation"
data-error="{name}"
>{validationMessage}</small>
>{(validationMessage || nativeError)}</small>
</fieldset>
}
style {
.wrn-next--toggle-password .wrn-next__validation {
color: var(--wrn-color-danger, #ef4444);
}
.wrn-next--toggle-password {
display: grid;
width: 100%;
+47 -5
View File
@@ -25,6 +25,7 @@ component Input {
helperText: string = ""
cornerHint: string = ""
error: string = ""
invalidMessage: string = ""
inline: boolean = false
readonly: boolean = false
disabled: boolean = false
@@ -56,7 +57,16 @@ component Input {
client function handleChange(sourceEvent) { sourceEvent.stopPropagation(); output.change(detail(sourceEvent)) }
client function handleFocus(sourceEvent) { sourceEvent.stopPropagation(); output.focus(detail(sourceEvent)) }
client function handleBlur(sourceEvent) { sourceEvent.stopPropagation(); output.blur(detail(sourceEvent)) }
client function handleInvalid(sourceEvent) { sourceEvent.stopPropagation(); validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent }) }
client function handleInvalid(sourceEvent) {
sourceEvent.preventDefault()
sourceEvent.stopPropagation()
const field = sourceEvent.currentTarget
const validity = field.validity
validationError = error || invalidMessage || (validity.valueMissing ? "This field is required." : validity.typeMismatch ? "Enter a valid " + (type === "email" ? "email address." : "value.") : validity.tooShort ? "Enter at least " + minlength + " characters." : validity.tooLong ? "Enter no more than " + maxlength + " characters." : validity.patternMismatch ? "Enter a value in the required format." : validity.rangeUnderflow ? "Enter a value of at least " + min + "." : validity.rangeOverflow ? "Enter a value no greater than " + max + "." : "Enter a valid value.")
field.setAttribute("aria-invalid", "true")
field.closest(".wrn-next--field").setAttribute("data-invalid", "true")
output.invalid({ value: field.value, name: name, message: validationError, sourceEvent: sourceEvent })
}
client function handleKeydown(sourceEvent) { sourceEvent.stopPropagation(); output.keydown({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) }
client function handleKeyup(sourceEvent) { sourceEvent.stopPropagation(); output.keyup({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) }
}
@@ -68,6 +78,7 @@ component Input {
data-inline="{inline}"
data-floating="{variant === 'floating'}"
data-invalid="{error || validationError ? 'true' : 'false'}"
data-input-type="{type}"
>
<div
class="wrn-next__field-heading"
@@ -89,10 +100,11 @@ component Input {
<div
class="wrn-next__field-control"
data-icon-position="{iconPosition}"
data-has-icon="{icon || type === 'date' || type === 'datetime-local' || type === 'time' || type === 'month' || type === 'week' ? 'true' : 'false'}"
>
{#if icon}
{#if icon || type === "date" || type === "datetime-local" || type === "time" || type === "month" || type === "week"}
<span
class="{icon} wrn-next__field-icon"
class="{icon || (type === 'time' ? 'icon-[lucide--clock-3]' : type === 'datetime-local' ? 'icon-[lucide--calendar-clock]' : 'icon-[lucide--calendar-days]')} wrn-next__field-icon"
aria-hidden="true"
>
</span>
@@ -144,6 +156,7 @@ component Input {
id="{(id || name) + '-error'}"
class="wrn-next__field-error"
data-error="{name}"
data-empty="{error || validationError ? 'false' : 'true'}"
aria-live="polite"
>
{error || validationError}
@@ -298,6 +311,12 @@ component Input {
font-size: 0.8125rem;
font-weight: 500;
}
/* Input is a block-level control. Shared field sizing must not cap it when
it participates in a grid or flex layout owned by the consumer. */
.wrn-next--field.wrn-next--input {
width: 100%;
max-width: none;
}
.wrn-next--field.wrn-next--size-sm,
.wrn-next--choice-field.wrn-next--size-sm {
font-size: 0.8125rem;
@@ -336,10 +355,29 @@ component Input {
background: var(--wrn-color-surface);
font-size: 0.8125rem;
}
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
.wrn-next__field-control[data-has-icon="true"][data-icon-position="start"] > :is(input, textarea, select) {
padding-inline-start: 2.5rem;
}
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
.wrn-next--input[data-input-type="date"] .wrn-next__field-control,
.wrn-next--input[data-input-type="datetime-local"] .wrn-next__field-control,
.wrn-next--input[data-input-type="time"] .wrn-next__field-control,
.wrn-next--input[data-input-type="month"] .wrn-next__field-control,
.wrn-next--input[data-input-type="week"] .wrn-next__field-control {
color-scheme: light dark;
}
.wrn-next--input :is(input[type="date"], input[type="datetime-local"], input[type="time"], input[type="month"], input[type="week"]) {
min-width: 0;
font-variant-numeric: tabular-nums;
}
.wrn-next--input :is(input[type="date"], input[type="datetime-local"], input[type="time"], input[type="month"], input[type="week"])::-webkit-calendar-picker-indicator {
width: 1.1rem;
height: 1.1rem;
margin: 0;
padding: 0;
cursor: pointer;
opacity: 0.72;
}
.wrn-next__field-control[data-has-icon="true"][data-icon-position="end"] > :is(input, textarea, select) {
padding-inline-end: 2.5rem;
}
.wrn-next__field-icon {
@@ -423,6 +461,10 @@ component Input {
.wrn-next__field-error:empty {
display: none;
}
.wrn-next__field-error[data-empty="true"] {
display: none;
min-height: 0;
}
.wrn-next--field[data-invalid="true"] :is(input, textarea, select),
.wrn-next--choice-field[data-invalid="true"] input {
border-color: var(--wrn-color-danger);
+7 -3
View File
@@ -46,7 +46,7 @@ size: string = "default"
}
client function handleFocus(sourceEvent) { emitField("focus", sourceEvent); output.open({ name: name, sourceEvent: sourceEvent }) }
client function handleBlur(sourceEvent) { emitField("blur", sourceEvent); output.close({ name: name, sourceEvent: sourceEvent }) }
client function handleInvalid(sourceEvent) { validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ name: name, message: validationError, sourceEvent: sourceEvent }) }
client function handleInvalid(sourceEvent) { sourceEvent.preventDefault(); sourceEvent.stopPropagation(); validationError = error || "Choose an option."; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ name: name, message: validationError, sourceEvent: sourceEvent }) }
}
view {
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--select {class}" data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" data-invalid="{error || validationError ? 'true' : 'false'}" data-readonly="{readonly}">
@@ -57,7 +57,7 @@ size: string = "default"
{#if !multiple}<option value="" selected="{value === ''}" disabled="{required}">{placeholder}</option>{/if}
{#each options as option}<option value="{option.value}" selected="{selected(option)}" disabled="{option.disabled}">{option.label}</option>{/each}
</select>
{#if !multiple}<span class="icon-[lucide--chevron-down] wrn-next__select-indicator" aria-hidden="true"></span>{/if}
{#if !multiple}<svg class="wrn-next__select-indicator" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg>{/if}
{#if variant === "floating"}<span class="wrn-next__field-floating-label">{label}</span>{/if}
</div>
{#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if}
@@ -79,7 +79,11 @@ size: string = "default"
.wrn-next__select-indicator {
position: absolute;
right: 0.85rem;
inset-inline-end: 0.85rem;
top: 50%;
transform: translateY(-50%);
width: 1rem;
height: 1rem;
color: var(--wrn-color-muted);
pointer-events: none;
}
+3 -1
View File
@@ -40,7 +40,9 @@ size: string = "default"
output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent })
}
client function handleInvalid(sourceEvent) {
validationError = sourceEvent.currentTarget.validationMessage
sourceEvent.preventDefault()
sourceEvent.stopPropagation()
validationError = error || (sourceEvent.currentTarget.validity.valueMissing ? "This field is required." : "Enter text within the allowed length.")
sourceEvent.currentTarget.setAttribute("aria-invalid", "true")
sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true")
output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent })
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.8.56",
"version": "0.8.70",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -62,7 +62,7 @@ export interface MegaMenuColumn {
actionHref?: string;
}
export interface MegaMenuRailItem extends NavigationLink {}
export type MegaMenuRailItem = NavigationLink;
export interface MegaMenuFeatured {
eyebrow?: string;
@@ -0,0 +1,234 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { renderComponent, mountHtml } from "../../test/src/index.ts";
import { uiComponentPath } from "../src/registry.ts";
import { parse } from "../../compiler/src/index.ts";
import { Window } from "happy-dom";
const source = (name: string) => readFileSync(uiComponentPath(name), "utf8");
describe("account shell UI regressions", () => {
test("Drawer has a self-contained close icon and a compact centered mobile header", async () => {
const dom = mountHtml(
await renderComponent(source("Drawer"), { title: "Account center", open: true, duration: 0 }),
);
const close = dom.querySelector(".wrn-drawer__close") as HTMLButtonElement;
expect(close.getAttribute("aria-label")).toBe("Close drawer");
expect(close.querySelector('svg[aria-hidden="true"] path')).not.toBeNull();
expect(close.querySelector('[class*="icon-"]')).toBeNull();
expect(source("Drawer")).toMatch(/\.wrn-drawer__header\s*\{[^}]*align-items: center;/);
expect(source("Drawer")).toContain("padding-block: 0.75rem;");
let closed = false;
dom.querySelector('[data-ui-component="Drawer"]')?.addEventListener("wrnexus:close", () => {
closed = true;
});
close.click();
expect(closed).toBe(true);
});
test("Dropdown allows anchored mobile menus without changing existing sheet defaults", async () => {
for (const mobilePanel of [undefined, "dropdown", "sheet"]) {
const dom = mountHtml(
await renderComponent(source("Dropdown"), {
label: "User",
placement: "bottom-end",
...(mobilePanel ? { mobilePanel } : {}),
}),
);
expect(dom.querySelector(".wrn-dropdown")?.getAttribute("data-mobile-panel")).toBe(
mobilePanel ?? "sheet",
);
(dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement).click();
expect(dom.querySelector(".wrn-dropdown__panel")?.hasAttribute("hidden")).toBe(false);
}
expect(source("Dropdown")).not.toContain(
".wrn-dropdown[data-placement] .wrn-dropdown__panel {",
);
});
test("Dropdown is natively hidden before CSS or hydration and still toggles afterward", async () => {
for (const props of [{}, { open: true }, { defaultOpen: true }]) {
const html = await renderComponent(source("Dropdown"), { label: "User", ...props });
const server = new Window();
try {
server.document.body.innerHTML = html;
const closed = !(props.open || props.defaultOpen);
for (const selector of [".wrn-dropdown__panel", ".wrn-dropdown__dismiss-layer"]) {
expect(server.document.querySelector(selector)?.hasAttribute("hidden")).toBe(closed);
}
} finally {
await server.happyDOM.close();
}
}
const dom = mountHtml(await renderComponent(source("Dropdown"), { label: "User" }));
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
const panel = dom.querySelector(".wrn-dropdown__panel")!;
expect(panel.hasAttribute("hidden")).toBe(true);
trigger.click();
expect(panel.hasAttribute("hidden")).toBe(false);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
trigger.click();
expect(panel.hasAttribute("hidden")).toBe(true);
});
test("Sidebar supplies navigation to its mobile Drawer without an app slot", async () => {
const dom = mountHtml(
await renderComponent(source("Sidebar"), {
items: [
{ heading: "Account", items: [{ label: "Profile", href: "/profile", value: "profile" }] },
],
active: "profile",
}),
);
const mobileNav = dom.querySelector(".wrn-sidebar__drawer-nav");
expect(mobileNav?.querySelector('a[href="/profile"]')?.getAttribute("aria-current")).toBe(
"page",
);
expect(dom.querySelector('.wrn-sidebar__rail a[href="/profile"]')).not.toBeNull();
});
test("Sidebar embedded in another drawer does not generate a second mobile drawer", async () => {
const dom = mountHtml(
await renderComponent(source("Sidebar"), {
mobile: false,
items: [{ label: "Profile", href: "/profile" }],
}),
);
expect(dom.querySelector('[data-component="Drawer"]')).toBeNull();
expect(dom.querySelector(".wrn-sidebar")?.getAttribute("data-mobile")).toBe("false");
});
test("Sidebar blocks disabled links at every nesting level", async () => {
const dom = mountHtml(
await renderComponent(source("Sidebar"), {
items: [
{
label: "Parent",
disabled: true,
href: "/parent",
items: [
{
label: "Child",
disabled: true,
href: "/child",
items: [{ label: "Leaf", disabled: true, href: "/leaf" }],
},
],
},
],
}),
);
for (const href of ["/parent", "/child", "/leaf"]) {
const link = dom.querySelector(`.wrn-sidebar__rail a[href="${href}"]`)!;
expect(link.getAttribute("aria-disabled")).toBe("true");
expect(link.getAttribute("tabindex")).toBe("-1");
const click = new (dom.window as { MouseEvent: typeof MouseEvent }).MouseEvent("click", {
bubbles: true,
cancelable: true,
});
link.dispatchEvent(click);
expect(click.defaultPrevented).toBe(true);
}
});
test("Dropdown keyboard navigation skips disabled links and ArrowUp starts at the last item", async () => {
const dom = mountHtml(
await renderComponent(source("Dropdown"), {
label: "Account",
items: [
{ label: "Profile", href: "/profile" },
{ label: "Disabled", href: "/disabled", disabled: true },
{ label: "Security", href: "/security" },
],
}),
);
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
// Execute the component's real JS functions: the lightweight mount runtime
// intentionally does not interpret nested timer callbacks.
const functions = parse(source("Dropdown"))
.functions.join("\n")
.replace(/\b(client|shared) function/g, "function");
const handle = new Function(
"document",
"window",
"output",
`
let open = false, visible = false, disabled = false;
${functions}
return handleKeydown;
`,
)(dom.document, dom.window, {
open() {},
close() {},
toggle({ open }: { open: boolean }) {
trigger.setAttribute("aria-expanded", String(open));
},
});
const key = (_target: Element, value: string) =>
handle({
key: value,
currentTarget: dom.querySelector(".wrn-dropdown"),
preventDefault() {},
stopPropagation() {},
});
trigger.focus();
key(trigger, "ArrowUp");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(dom.document.activeElement?.textContent).toContain("Security");
key(dom.document.activeElement!, "ArrowUp");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(dom.document.activeElement?.textContent).toContain("Profile");
key(dom.document.activeElement!, "Escape");
expect(dom.document.activeElement).toBe(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
test("Dropdown preserves falsy action values and has an explicit accessible trigger name", async () => {
const dom = mountHtml(
await renderComponent(source("Dropdown"), {
label: "",
ariaLabel: "Account menu",
items: [{ label: "Zero", value: 0, action: true }],
}),
);
let value: unknown = undefined;
dom.querySelector(".wrn-dropdown")!.addEventListener("action", (event) => {
value = (event as CustomEvent).detail.value;
});
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
expect(trigger.getAttribute("aria-label")).toBe("Account menu");
trigger.click();
(dom.querySelector("[data-dropdown-item]") as HTMLButtonElement).click();
expect(value).toBe(0);
});
test("Dropdown closes when keyboard focus leaves the component", async () => {
const dom = mountHtml(
await renderComponent(source("Dropdown"), {
label: "Menu",
items: [{ label: "Profile", href: "/profile" }],
}),
);
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
trigger.click();
const outside = dom.document.createElement("button");
dom.document.body.append(outside);
trigger.dispatchEvent(
new (dom.window as { FocusEvent: typeof FocusEvent }).FocusEvent("focusout", {
bubbles: true,
relatedTarget: outside,
}),
);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
test("layout fixes belong to components, not consuming applications", () => {
expect(source("Sidebar")).toMatch(
/\.wrn-sidebar__icon\s*\{[^}]*width: 1.05rem;[^}]*height: 1.05rem;/s,
);
expect(source("Sidebar")).toContain("margin-inline-start");
expect(source("Dropdown")).toContain(
'.wrn-dropdown[data-placement][data-mobile-panel="sheet"] .wrn-dropdown__panel',
);
expect(source("Footer")).toMatch(/\.wrn-footer__copyright-left\s*\{\s*grid-column: 1;/);
expect(source("Footer")).toMatch(/\.wrn-footer__copyright-right\s*\{\s*grid-column: 3;/);
});
});
+189 -3
View File
@@ -1294,6 +1294,10 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
expect(validation.error).toBe("Enter a valid email address.");
const source = readFileSync(uiComponentPath("Input"), "utf8");
const sharedFieldRule = source.lastIndexOf(".wrn-next--field,");
const inputWidthRule = source.lastIndexOf(".wrn-next--field.wrn-next--input");
expect(inputWidthRule).toBeGreaterThan(sharedFieldRule);
expect(source.slice(inputWidthRule, inputWidthRule + 140)).toContain("max-width: none");
const dom = mountHtml(
await renderComponent(source, {
id: "account-email",
@@ -1317,6 +1321,7 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
expect(root.dataset.variant).toBe("floating");
expect(root.dataset.inline).toBe("true");
expect(root.dataset.invalid).toBe("true");
expect(dom.querySelector(".wrn-next__field-error")?.getAttribute("data-empty")).toBe("false");
expect(input.id).toBe("account-email");
expect(input.name).toBe("email");
expect(input.value).toBe("not-an-email");
@@ -1344,7 +1349,39 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
expect(emitted).toEqual(["input", "change", "focus", "blur", "keydown", "keyup"]);
});
test("basic fields render native constraint messages and select matches field surfaces", async () => {
test("Input does not reserve validation space while its error is empty", async () => {
const source = readFileSync(uiComponentPath("Input"), "utf8");
const dom = mountHtml(await renderComponent(source, { name: "search", hiddenLabel: true }));
expect(dom.querySelector(".wrn-next__field-error")?.getAttribute("data-empty")).toBe("true");
expect(source).toContain('.wrn-next__field-error[data-empty="true"]');
});
test("Input only reserves icon space when it renders an icon", async () => {
const source = readFileSync(uiComponentPath("Input"), "utf8");
const plain = mountHtml(
await renderComponent(source, { name: "limit", label: "Limit", placeholder: "Enter limit" }),
);
const decorated = mountHtml(
await renderComponent(source, {
name: "email",
label: "Email",
icon: "icon-[lucide--mail]",
}),
);
expect(plain.querySelector(".wrn-next__field-control")?.getAttribute("data-has-icon")).toBe(
"false",
);
expect(plain.querySelector(".wrn-next__field-icon")).toBeNull();
expect(decorated.querySelector(".wrn-next__field-control")?.getAttribute("data-has-icon")).toBe(
"true",
);
expect(decorated.querySelector(".wrn-next__field-icon")).not.toBeNull();
expect(source).toContain('[data-has-icon="true"][data-icon-position="start"]');
expect(source).toContain('[data-has-icon="true"][data-icon-position="end"]');
});
test("basic fields suppress native popups and render package constraint messages", async () => {
const inputSource = readFileSync(uiComponentPath("Input"), "utf8");
const dom = mountHtml(
await renderComponent(inputSource, {
@@ -1355,9 +1392,14 @@ test("basic fields render native constraint messages and select matches field su
}),
);
const input = dom.querySelector("input") as HTMLInputElement;
input.dispatchEvent(new (dom.window as any).Event("invalid", { bubbles: false }));
const invalidEvent = new (dom.window as any).Event("invalid", {
bubbles: false,
cancelable: true,
});
input.dispatchEvent(invalidEvent);
expect(invalidEvent.defaultPrevented).toBe(true);
expect(dom.querySelector("[data-error='name']")?.textContent?.trim()).toBe(
input.validationMessage,
"This field is required.",
);
expect(dom.querySelector(".wrn-next--input")?.getAttribute("data-invalid")).toBe("true");
@@ -1367,6 +1409,74 @@ test("basic fields render native constraint messages and select matches field su
expect(selectSource).toContain("wrn-next__select-indicator");
});
test("Select renders its own directional indicator without app icon CSS", async () => {
const source = readFileSync(uiComponentPath("Select"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
name: "method",
value: "totp",
options: [{ label: "Authenticator app", value: "totp" }],
}),
);
const indicator = dom.querySelector("svg.wrn-next__select-indicator");
expect(indicator?.getAttribute("width")).toBe("16");
expect(indicator?.getAttribute("height")).toBe("16");
expect(indicator?.getAttribute("aria-hidden")).toBe("true");
expect(indicator?.querySelector("path")?.getAttribute("d")).toBe("m6 9 6 6 6-6");
expect(source).toContain("inset-inline-end: 0.85rem");
expect(source).not.toContain("icon-[lucide--chevron-down]");
const multiple = mountHtml(await renderComponent(source, { multiple: true }));
expect(multiple.querySelector(".wrn-next__select-indicator")).toBeNull();
});
test("OTP inputs accept six digits and show custom inline errors without native popups", async () => {
const source = readFileSync(uiComponentPath("Input"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
name: "code",
pattern: "[0-9]{6}",
required: true,
invalidMessage: "Enter the six-digit verification code.",
}),
);
const input = dom.querySelector("input") as HTMLInputElement;
input.value = "737651";
expect(input.checkValidity()).toBe(true);
input.value = "123";
const event = new (dom.window as any).Event("invalid", { cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(dom.querySelector("[data-error='code']")?.textContent).toContain(
"Enter the six-digit verification code.",
);
});
test("password fields cancel native invalid popups and show inline errors", async () => {
for (const component of ["TogglePassword", "StrongPassword"]) {
const dom = mountHtml(
await renderComponent(readFileSync(uiComponentPath(component), "utf8"), {
name: "password",
required: true,
}),
);
const input = dom.querySelector("input") as HTMLInputElement;
const event = new (dom.window as any).Event("invalid", { cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(dom.querySelector("[data-error='password']")?.textContent).toContain(
"Enter your password.",
);
expect(dom.querySelector(".wrn-next--invalid")).not.toBeNull();
const source = readFileSync(uiComponentPath(component), "utf8");
expect(source).toMatch(
/\.wrn-next--(?:toggle|strong)-password \.wrn-next__validation\s*\{\s*color: var\(--wrn-color-danger, #ef4444\)/,
);
input.value = "ExamplePassword123!";
input.dispatchEvent(new (dom.window as any).Event("input", { bubbles: true }));
expect(dom.querySelector(".wrn-next--invalid")).toBeNull();
}
});
test("all basic form components render values and their must-have interaction events", async () => {
const components = [
"Checkbox",
@@ -1837,6 +1947,82 @@ test("input number increments, decrements, applies steps, and respects limits",
expect(decrement().disabled).toBe(true);
});
test("advanced select keeps large closed lists compact and renders only selected tags", async () => {
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const options = Array.from({ length: 420 }, (_, i) => ({
value: String(i),
label: "Option " + i,
}));
const html = await renderComponent(source, {
name: "large",
value: "419",
options,
searchResultLimit: 40,
});
expect(Buffer.byteLength(html)).toBeLessThan(120_000);
const dom = mountHtml(html);
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(0);
expect(dom.querySelectorAll(".wrn-next__select-tags > span")).toHaveLength(0);
expect(dom.querySelector(".wrn-next__select-value")?.textContent).toContain("Option 419");
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(40);
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(0);
const multi = mountHtml(
await renderComponent(source, {
name: "selected-tags",
multiple: true,
values: ["419"],
options,
searchResultLimit: 4,
}),
);
expect(multi.querySelectorAll(".wrn-next__select-tags > span")).toHaveLength(1);
expect(multi.querySelector(".wrn-next__select-tags")?.textContent).toContain("Option 419");
(multi.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
(multi.querySelector(".wrn-next__select-option") as HTMLButtonElement).click();
expect(multi.querySelectorAll(".wrn-next__select-tags > span")).toHaveLength(2);
(multi.querySelector(".wrn-next__clear-select") as HTMLButtonElement).click();
expect(multi.querySelectorAll(".wrn-next__select-tags > span")).toHaveLength(0);
});
test("advanced select lazily opens grouped options and honors defaultOpen", async () => {
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const groups = [{ label: "Regions", options: [{ value: "in", label: "India" }] }];
const dom = mountHtml(await renderComponent(source, { name: "grouped", groups }));
expect(dom.querySelectorAll(".wrn-next__option-group")).toHaveLength(0);
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
expect(dom.querySelectorAll(".wrn-next__option-group")).toHaveLength(1);
(dom.querySelector(".wrn-next__select-option") as HTMLButtonElement).click();
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("in");
const opened = mountHtml(
await renderComponent(source, { name: "opened", groups, defaultOpen: true }),
);
expect(opened.querySelectorAll(".wrn-next__select-option")).toHaveLength(1);
});
test("advanced select limits rendered options but searches the full dataset and retains saved values", async () => {
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
name: "region",
value: "99",
searchResultLimit: 4,
options: Array.from({ length: 100 }, (_, i) => ({ value: String(i), label: "Region " + i })),
}),
);
expect(dom.querySelector(".wrn-next__select-value")?.textContent).toContain("Region 99");
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(0);
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(4);
const search = dom.querySelector('input[type="search"]') as HTMLInputElement;
search.value = "Region 99";
search.dispatchEvent(new (dom.window as any).Event("input", { bubbles: true }));
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(1);
expect(dom.querySelector(".wrn-next__select-option")?.textContent).toContain("Region 99");
});
test("advanced select opens, filters, and selects without reactive handler errors", async () => {
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const html = await renderComponent(source, {