Compare commits

...
9 Commits
Author SHA1 Message Date
Clintchiz a3ddd39b7b feat: centralize application framework primitives
Quality / quality (ubuntu-latest) (push) Failing after 14m38s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-22 23:07:46 +05:30
ClintchizandClaude Opus 5 96e082b943 release: patch syntax, compiler, db, csr
Six fixes, all found by driving a real application rather than by the suite:

- syntax: a quote or brace inside a regex literal unbalanced the brace scanner
- syntax: block comments between members failed to parse, while the same
  comment inside a braced body was fine
- compiler: pages never emitted `data-wrn-loop-locals`, so a loop variable in
  a handler threw ReferenceError at click time with a green build
- csr: client-rendered `data-for` items never carried the marker either, so a
  component's output binding silently dropped every call while a plain DOM
  handler in the same position worked
- db: the query generator baked the checkout's line endings into generated
  SQL literals, so every build dirtied the working tree

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:09:38 +05:30
ClintchizandClaude Opus 5 2299a0726d chore: fit the loop-locals fix within the production gate
Three things the gate caught that the test suite could not.

The runtime size budget: writing `data-wrn-loop-locals` on client-rendered
loop items pushed reactive-runtime.ts to 51,603 against a 51,400 budget that
had only 125 bytes of headroom. Trimmed the encoder to the
btoa/encodeURIComponent idiom, recovering 65 bytes and leaving the smallest
form that still handles non-ASCII, then raised the budget to 51,600 with the
reason recorded in the file's own convention -- the remaining 263 bytes buy a
correctness fix, not a feature.

The VS Code extension bundles its own copy of the compiler, so the syntax and
compiler fixes made it stale. Rebuilt.

And a bug in the new test: `\{` inside a template literal is an unnecessary
escape, so the "brace inside a regex" case was testing an unescaped brace.
`\{` tests the case it was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:08:07 +05:30
ClintchizandClaude Opus 5 2d0df4efc9 fix(csr): write loop locals onto client-rendered for-loop items
A client `data-for` passed its loop locals to hydration in memory but never
wrote the `data-wrn-loop-locals` attribute the SSR path writes. Anything that
resolves locals by READING the DOM -- notably a component's `data-wrn-out-*`
output binding, which calls `decodeLoopLocals(componentRoot)` -- therefore
found nothing and silently dropped the call, with no console error.

A plain DOM handler kept working, because it receives locals through the
hydration closure instead, which is what made the failure look arbitrary: the
same loop variable resolved for `@click` and vanished for a component output.

Both loop paths write the marker now, keyed and non-keyed, so the DOM is the
single source of truth. Encoding goes through UTF-8 before base64 as the
server's does; `btoa` on a raw string throws above U+00FF, which would take the
whole loop down for an ordinary non-ASCII label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:31:11 +05:30
ClintchizandClaude Opus 5 20783699ac test(compiler): prove page loop locals render, not just emit
The existing tests assert the marker is emitted. This one executes the
generated module and asserts the rendered HTML carries each item's real,
decodable values -- generated text that reads correctly can still render
wrong, and what matters is what the runtime finds in the DOM at click time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 07:38:40 +05:30
ClintchizandClaude Opus 5 d8305a5a14 fix(db): normalise line endings when parsing queries
The generator embeds each query's SQL as a string literal, taking whatever line
endings the checkout happened to have. On a CRLF checkout every regenerated
query differed from the committed one by `\n` -> `\r\n`, so `wrnexus build`
dirtied the working tree and that churn buried real changes in the same file --
which is how a hand-applied edit ends up preferable to running the generator.

Line endings carry no meaning in SQL, so normalise on parse and let generated
output be stable across platforms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 07:28:29 +05:30
ClintchizandClaude Opus 5 98a46091b5 fix(syntax): allow block comments between members
`skipTrivia` skipped `// line comments` but not `/* block comments */`, so one
written between two page or component members failed with a bare "Unexpected
character '/'". Block comments inside a braced body already worked, which made
the failure look arbitrary: the same comment parsed or did not depending on
whether it happened to sit inside a block.

`startsWithBlockComment` now skips only whitespace and line comments, so
`props {}` keeps refusing block comments with its own explained error rather
than silently swallowing one and dropping the declaration after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 06:50:51 +05:30
ClintchizandClaude Opus 5 2c50a07ed2 fix(compiler): expose {#each} locals to event handlers on pages
A handler expression is emitted as text and evaluated when the event fires, so
any loop variable it names has to travel with the element. Components emitted
`data-wrn-loop-locals` for this; pages did not. The same view worked inside a
component and threw ReferenceError inside a page -- with a green build and green
tests, since nothing renders the page in a browser during a build.

The CSR runtime already resolves locals generically via
closest("[data-wrn-loop-locals]"), so only codegen needed to change.

The marker is emitted only on elements that actually bind an event, and the
encoder only when a marker was produced -- but it MUST be emitted whenever one
is, or the render throws on an undefined function instead of the handler
throwing on an undefined variable, which is strictly worse. Covered by its own
test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 06:05:10 +05:30
ClintchizandClaude Opus 5 9746e8e875 fix(syntax): do not let a regex literal unbalance a block
The brace scanner knew about strings and comments but had no case for regex
literals. A quote inside one opened a phantom string that swallowed every brace
until the next quote; a lone `{` or `}` inside one miscounted block depth. Both
failed the component with "Unbalanced braces" pointing at the block's first line.

`/-/g` parsed fine, which is why this went unnoticed -- it needs a quote or a
brace inside the pattern to bite.

Regex-vs-division is decided by scanning back to the last significant
character, erring towards division: mistaking division for a regex would
swallow code to the next `/` and lose any braces between. A regex cannot span a
newline, so an unterminated one on the line is treated as "not a regex", which
is what keeps a bare URL in view text intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 06:05:09 +05:30
84 changed files with 2229 additions and 124 deletions
+11
View File
@@ -2,6 +2,17 @@
## Unreleased
- Added framework-owned authentication and authorization integration: lazy auth stores,
configured OAuth routes with cookie-bound transactions, production configuration validation,
default lifecycle roles, typed request users, declarative API permissions, page guards, and
consistent unauthorized/forbidden responses.
- Added reusable application primitives for typed route params, bounded cursor pagination,
owned-resource authorization, atomic database state transitions, queue batching and explicit
worker lifecycles.
- Expanded `@wrnexus/test` with official typed contexts, Bun-compatible fetch mocking and a
full-stack harness alias; made generated files deterministic and introduced `wrnexus check` as
the canonical build-before-check command for generated applications.
- Fixed `v.boolean()` coercion in `@wrnexus/validation` (`checkField` in both `src/index.ts`
and the browser mirror in `src/runtime.ts`): previously any string other than `"true"` or
`"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g.
+13 -12
View File
@@ -235,7 +235,7 @@
},
"packages/auth": {
"name": "@wrnexus/auth",
"version": "0.8.14",
"version": "0.8.15",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/captcha": "workspace:*",
@@ -257,10 +257,11 @@
},
"packages/authz": {
"name": "@wrnexus/authz",
"version": "0.8.9",
"version": "0.8.10",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/db": "workspace:*",
"@wrnexus/plugin": "workspace:*",
},
},
"packages/benchmark": {
@@ -291,7 +292,7 @@
},
"packages/cli": {
"name": "@wrnexus/cli",
"version": "0.8.48",
"version": "0.8.49",
"bin": {
"wrnexus": "src/index.ts",
},
@@ -319,7 +320,7 @@
},
"packages/compiler": {
"name": "@wrnexus/compiler",
"version": "0.8.16",
"version": "0.8.17",
"dependencies": {
"@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*",
@@ -337,18 +338,18 @@
},
"packages/core": {
"name": "@wrnexus/core",
"version": "0.8.11",
"version": "0.8.12",
},
"packages/csr": {
"name": "@wrnexus/csr",
"version": "0.8.27",
"version": "0.8.28",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/db": {
"name": "@wrnexus/db",
"version": "0.8.16",
"version": "0.8.18",
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^6.0.3",
@@ -356,7 +357,7 @@
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
"version": "0.8.44",
"version": "0.8.45",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*",
@@ -545,7 +546,7 @@
},
"packages/queue": {
"name": "@wrnexus/queue",
"version": "0.8.8",
"version": "0.8.9",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/rpc": "workspace:*",
@@ -632,7 +633,7 @@
},
"packages/styles": {
"name": "@wrnexus/styles",
"version": "0.8.16",
"version": "0.8.17",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
@@ -641,11 +642,11 @@
},
"packages/syntax": {
"name": "@wrnexus/syntax",
"version": "0.8.11",
"version": "0.8.13",
},
"packages/test": {
"name": "@wrnexus/test",
"version": "0.8.9",
"version": "0.8.10",
},
"packages/tracking": {
"name": "@wrnexus/tracking",
+39 -3
View File
@@ -91,6 +91,7 @@
"AuthPluginOptions",
"AuthPublicUser",
"AuthRandom",
"AuthRequiredError",
"AuthResult",
"AuthRiskDecision",
"AuthRiskLevel",
@@ -210,6 +211,7 @@
"signUpSchema",
"totpUri",
"tryGetDefaultAuthEngine",
"validateProductionAuthConfig",
"verificationRequestSchema",
"verificationTokenSchema",
"verifyTotp"
@@ -229,6 +231,7 @@
],
"./middleware": [
"AUTH_SESSION_KEY",
"AuthRequiredError",
"AuthSessionOptions",
"RequireAuthOptions",
"authSession",
@@ -237,7 +240,8 @@
"getAuthSession",
"getAuthUser",
"isAuthenticatedContext",
"requireAuth"
"requireAuth",
"requireAuthUser"
],
"./passkeys": [
"MemoryPasskeyChallengeStore",
@@ -252,11 +256,13 @@
"./plugin": [
"AuthAuditIssue",
"AuthConfig",
"AuthOAuthProviderConfig",
"AuthPluginOptions",
"AuthRoutesConfig",
"authComponentsDir",
"authPlugin",
"default"
"default",
"validateProductionAuthConfig"
],
"./protector": [
"createAuthSecretProtector"
@@ -460,10 +466,14 @@
"AUTHZ_LOCALS_KEY",
"AttributeMeta",
"AuthorizationDecision",
"AuthorizationResponses",
"AuthorizeDecisionOptions",
"AuthorizedHandlerOptions",
"AuthzAuditEvent",
"AuthzAuditSink",
"AuthzCatalog",
"AuthzConfig",
"AuthzIdentityEvent",
"AuthzModule",
"AuthzResolver",
"AuthzResolverOptions",
@@ -476,10 +486,12 @@
"GrantEffect",
"GuardOptions",
"MemoryAuditSink",
"OwnedResourceDefinition",
"PermissionMeta",
"PermissionStore",
"Policy",
"Rbac",
"RequestAuthorization",
"Subject",
"SubjectAssignments",
"all",
@@ -487,17 +499,21 @@
"allow",
"any",
"anyDecision",
"assignDefaultAuthzRoles",
"attr",
"authorize",
"authorizeDecision",
"authzMiddleware",
"authzPlugin",
"cachedPermissionStore",
"can",
"consoleAuditSink",
"createAuthzResolver",
"decideFor",
"decision",
"defineAuthorizedHandler",
"defineAuthz",
"defineOwnedResource",
"defineRbac",
"deniedBy",
"deny",
@@ -507,6 +523,7 @@
"filterCan",
"generatePermissionTypes",
"getAuthzCatalog",
"getRequestAuthorization",
"guardPermission",
"hasAuthzCatalog",
"hasRole",
@@ -519,12 +536,18 @@
"requireRole",
"safeRecord",
"scopeKey",
"setAuthzCatalog"
"setAuthzCatalog",
"setDefaultAuthzRoles"
],
"./db": [
"authzMigrationSql",
"dbPermissionStore",
"ensureAuthzTables"
],
"./plugin": [
"AuthzConfig",
"authzPlugin",
"default"
]
},
"@wrnexus/benchmark": {
@@ -1137,6 +1160,8 @@
"POSTGRES_TENANT_DIRECTORY_SCHEMA",
"PageComponent",
"PageMeta",
"PaginationInput",
"PaginationOptions",
"PerformanceBudgets",
"PerformanceMeasurement",
"PermissionsPolicyConfig",
@@ -1198,6 +1223,7 @@
"TenantResource",
"TenantSqlClient",
"Tracer",
"TransactionCookie",
"TrustedTypesConfig",
"UploadError",
"UploadInspectionResult",
@@ -1380,6 +1406,7 @@
"applyMigrations",
"batch",
"closeDatabases",
"compareAndSet",
"countRows",
"createDb",
"createRepository",
@@ -2260,6 +2287,7 @@
"SqlQueueClient",
"SubjectJob",
"SubjectQueue",
"WorkerDefinition",
"WorkflowDefinition",
"WorkflowEngine",
"WorkflowRunContext",
@@ -2275,6 +2303,7 @@
"cronToInterval",
"defineDurableWorkflow",
"defineJob",
"defineWorker",
"defineWorkflow",
"memoryQueueStore",
"memoryWorkflowStore",
@@ -2283,6 +2312,7 @@
"redisQueueStore",
"renderQueueDashboard",
"runQueueDaemon",
"runWorker",
"subjectQueue"
]
},
@@ -2639,6 +2669,7 @@
".": [
"ACCENT_COOKIE",
"AppConfig",
"AppConfigInput",
"BrowserCookieApi",
"BrowserCookieOptions",
"BrowserCookiePreference",
@@ -2663,6 +2694,7 @@
"ObservabilityConfig",
"PerformanceConfig",
"PwaConfig",
"ResolvedAppConfig",
"ResolvedConfigLayers",
"ResolvedTheme",
"StyleProcessContext",
@@ -2877,6 +2909,7 @@
".": [
"BrowserArtifactPage",
"Deferred",
"FetchMock",
"Harness",
"HarnessOptions",
"JsonResponse",
@@ -2892,7 +2925,10 @@
"captureBrowserArtifacts",
"createContext",
"createFactory",
"createFetchMock",
"createHarness",
"createTestApp",
"createTestContext",
"deferred",
"describe",
"expect",
+184 -17
View File
@@ -1,6 +1,6 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: 3dd8f4ba41eb3b4086c63e530a5daa985c1591e76e23bdd0fc47c87eefe5a4a6
// WRN editor compiler source hash: 1e7593d6b6bd1c9b8439fc34a30f9db9f7ae64f1788365a74115170aa34cd185
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -1291,15 +1291,15 @@ function bakeLoopAttr(raw, typed = false) {
return out + escLit(attrEscape(raw.slice(last)));
}
/** Render one loop-body node to template-literal source (nested loops inline). */
function renderLoopBody(node) {
function renderLoopBody(node, locals = []) {
if (node.type === "text") {
return bakeLoopText(node.value);
}
if (node.type === "each") {
return compileEachExpr(node);
return compileEachExpr(node, locals);
}
if (node.type === "if") {
return compileIfExpr(node);
return compileIfExpr(node, locals);
}
const componentTag = isComponentTag(node.tag);
const attrs = node.attrs
@@ -1316,7 +1316,13 @@ function renderLoopBody(node) {
return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`);
})
.join("");
const inner = node.children.map(renderLoopBody).join("");
// A handler expression is emitted as text and evaluated at event time, so any
// `{#each}` variable it names has to travel with the element. The runtime
// resolves them with closest("[data-wrn-loop-locals]"). Only elements that
// actually bind an event need it -- marking every node would bloat the HTML.
const localsAttr = locals.length > 0 && node.attrs.some((attr) => attr.event) ? loopLocalsAttr(locals) : "";
const inner = node.children.map((child) => renderLoopBody(child, locals)).join("");
const openAttrs = attrs + localsAttr;
if (node.tag === "Static")
return inner;
if (node.tag === "Dynamic")
@@ -1360,26 +1366,37 @@ function renderLoopBody(node) {
if (island)
return escLit(island);
return (escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs +
openAttrs +
escLit(">") +
inner +
escLit("</div>"));
}
if (syntax_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return escLit(`<${node.tag}`) + attrs + escLit(">");
return escLit(`<${node.tag}`) + openAttrs + escLit(">");
}
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`</${node.tag}>`);
return escLit(`<${node.tag}`) + openAttrs + escLit(">") + inner + escLit(`</${node.tag}>`);
}
/**
* Build the ` data-wrn-loop-locals="..."` attribute for a page-rendered loop
* body. Deliberately not passed through escLit: the `${...}` must stay live so
* the values are encoded at render time.
*/
function loopLocalsAttr(locals) {
const entries = locals.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ");
return ` data-wrn-loop-locals="\${__wrnexusEncodeLoopLocals({ ${entries} })}"`;
}
/**
* Compile a `{#each list as item}` block to a `${}` template-literal interpolation
* that iterates the (server-evaluated) list and joins the per-item body. `list` is a
* JS expression evaluated where `ssr` data bindings are in scope as raw named values.
*/
function compileEachExpr(node) {
function compileEachExpr(node, outerLocals = []) {
const item = node.item;
const index = node.index ?? "__wi";
const body = node.body.map(renderLoopBody).join("");
const empty = node.empty.map(renderLoopBody).join("");
// A nested loop can reference the outer loop's variables too.
const locals = [...outerLocals, item, index];
const body = node.body.map((child) => renderLoopBody(child, locals)).join("");
const empty = node.empty.map((child) => renderLoopBody(child, outerLocals)).join("");
return ("${(() => { const __wl = Array.isArray(" +
node.list +
") ? (" +
@@ -1399,11 +1416,11 @@ function compileEachExpr(node) {
* that renders the first truthy branch's body (or the `{:else}` body, or "" when neither).
* Conditions are JS expressions evaluated in the surrounding server scope.
*/
function compileIfExpr(node) {
function compileIfExpr(node, locals = []) {
let expr = "``"; // no matching branch → empty string
for (let k = node.branches.length - 1; k >= 0; k--) {
const b = node.branches[k];
const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`";
const bodySrc = "`" + b.body.map((child) => renderLoopBody(child, locals)).join("") + "`";
expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr;
}
return "${" + expr + "}";
@@ -2282,6 +2299,18 @@ function generateInner(ast) {
if (needsRuntimeHelpers) {
out.push(`import { buildApiRequest as __wrnexusBuildApiRequest } from "@wrnexus/core";`);
out.push(ssrRuntimeSource());
// Loop bodies that bind an event carry their {#each} locals in an encoded
// attribute. Emitted only when the view actually produced one, so a page
// without handlers in a loop keeps the smaller prelude -- but it MUST be
// emitted whenever the marker is, or the render throws on an undefined
// function instead of the handler throwing on an undefined variable.
if (body.includes("__wrnexusEncodeLoopLocals(")) {
out.push(`function __wrnexusEncodeLoopLocals(value: Record<string, unknown>): string {
const json = JSON.stringify(value);
const buffer = (globalThis as { Buffer?: { from(i: string, e: string): { toString(e: string): string } } }).Buffer;
return buffer ? buffer.from(json, "utf8").toString("base64") : btoa(unescape(encodeURIComponent(json)));
}`);
}
out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`);
}
if (hasServerApis) {
@@ -6528,6 +6557,25 @@ function parse(source) {
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
}
switch (kw.value) {
case "auth": {
lx.next();
expect("eq");
const value = lx.next();
if (value.type !== "ident" || !["true", "false"].includes(value.value)) {
throw new ParseError(`Expected auth = true or false at offset ${value.pos}`);
}
security.auth = value.value === "true" ? "required" : "false";
break;
}
case "permission": {
lx.next();
expect("eq");
const value = expect("string").value.trim();
if (!value)
throw new ParseError(`Page permission cannot be empty at offset ${kw.pos}`);
security.permission = value;
break;
}
case "layout": {
// layout = "public" — selects app/layouts/<name>.wrn for this page.
lx.next();
@@ -7477,6 +7525,88 @@ exports.isIdentPart = isIdentPart;
* reimplementing it a second hand-rolled scanner is how apostrophes in
* prose used to swallow braces.
*/
/**
* Identifiers that can precede a `/` without ending an expression, so the `/`
* opens a regex rather than dividing.
*/
const REGEX_PRECEDING_KEYWORDS = new Set([
"return",
"typeof",
"instanceof",
"in",
"of",
"new",
"delete",
"void",
"do",
"else",
"yield",
"await",
"case",
]);
/**
* Decide whether the `/` at `i` opens a regex literal or is a division sign.
*
* Scans backwards for the last significant character. `a / b` divides; `(/a/)`,
* `= /a/` and `return /a/` do not. Erring towards division is the safe
* direction -- mistaking division for a regex would swallow everything to the
* next `/` and lose any braces in between.
*/
function opensRegex(src, i) {
let j = i - 1;
while (j >= 0 && (src[j] === " " || src[j] === "\t" || src[j] === "\r" || src[j] === "\n"))
j--;
if (j < 0)
return true;
const prev = src[j];
if (/[A-Za-z0-9_$]/.test(prev)) {
// An identifier ends an expression, so `/` divides -- unless it is a
// keyword that cannot end one, like `return`.
let k = j;
while (k >= 0 && /[A-Za-z0-9_$]/.test(src[k]))
k--;
return REGEX_PRECEDING_KEYWORDS.has(src.slice(k + 1, j + 1));
}
// `)` and `]` close an expression, `.` continues one, and a quote ends a
// literal; anything else leaves us in a position where a regex may start.
return (prev !== ")" && prev !== "]" && prev !== "." && prev !== '"' && prev !== "'" && prev !== "`");
}
/**
* Scan a regex literal starting at `i`, returning the index just past its
* closing `/` and flags, or null when this is not in fact a regex.
*
* A regex literal cannot span a newline, so an unterminated one is treated as
* "not a regex" rather than swallowing the rest of the file. That is what keeps
* a bare URL in view text (`https://example.com/a//b`) intact.
*/
function skipRegex(src, i) {
let j = i + 1;
let inClass = false;
while (j < src.length) {
const c = src[j];
if (c === "\n")
return null;
if (c === "\\") {
j += 2;
continue;
}
if (inClass) {
if (c === "]")
inClass = false;
}
else if (c === "[") {
inClass = true;
}
else if (c === "/") {
j++;
while (j < src.length && /[a-z]/.test(src[j]))
j++;
return j;
}
j++;
}
return null;
}
function skipLiteralOrComment(src, i, atLineStart) {
const c = src[i];
if (c === "/" && src[i + 1] === "*") {
@@ -7500,6 +7630,13 @@ function skipLiteralOrComment(src, i, atLineStart) {
}
return src.length;
}
// A regex literal is neither a string nor a brace pair, but it can contain
// both. Without this, a quote inside one opened a phantom string that
// swallowed every brace to the next quote, and a lone `{`/`}` miscounted
// block depth.
if (c === "/" && opensRegex(src, i)) {
return skipRegex(src, i);
}
return null;
}
class Lexer {
@@ -7508,8 +7645,13 @@ class Lexer {
constructor(src) {
this.src = src;
}
/** Skip whitespace and `// line comments`. */
skipTrivia() {
/**
* Skip whitespace and `// line comments`, but NOT block comments.
*
* Kept separate from `skipTrivia` so `startsWithBlockComment` can still see a
* block comment that `skipTrivia` would otherwise consume.
*/
skipWhitespaceAndLineComments() {
const { src } = this;
while (this.pos < src.length) {
const c = src[this.pos];
@@ -7525,9 +7667,34 @@ class Lexer {
break;
}
}
/** True when the next non-trivia characters open a block comment. */
/**
* Skip whitespace, line comments and block comments.
*
* Block comments used to be skipped only inside a braced body, so one written
* between two members failed with a bare "Unexpected character '/'" -- the
* same comment parsed or did not depending on where it sat.
*/
skipTrivia() {
const { src } = this;
while (this.pos < src.length) {
this.skipWhitespaceAndLineComments();
if (src[this.pos] === "/" && src[this.pos + 1] === "*") {
const close = src.indexOf("*/", this.pos + 2);
this.pos = close === -1 ? src.length : close + 2;
continue;
}
break;
}
}
/**
* True when the next non-trivia characters open a block comment.
*
* Deliberately skips only whitespace and line comments: `props {}` refuses
* block comments with an explained error, and that check must run before
* `skipTrivia` would swallow the comment and drop a declaration silently.
*/
startsWithBlockComment() {
this.skipTrivia();
this.skipWhitespaceAndLineComments();
return this.src[this.pos] === "/" && this.src[this.pos + 1] === "*";
}
/** Read and consume the next structural token. */
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 59ff54353ed4f50aa53f3731e232726d55549936b2edf8958beb407cc4fdf9d2
// WRN editor extension source hash: 7fd27405852b02ce3f64e8b7cc08e84029aca9f5505217caafe56be8d3ca4931
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
+95 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// WRN editor language server source hash: e55270eb33d7722a82d13d3a79097fea77eca69612aeba104e9cbdd8fc67e8c6
// WRN editor language server source hash: 3c6060ff9db332cf1dc01a467d795fb2d002300400072a4b87437c48d61ab7f9
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -169671,6 +169671,64 @@ var isWs = (c) => c === " " || c === "\t" || c === `
` || c === "\r";
var isIdentStart = (c) => /[A-Za-z_]/.test(c);
var isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
var REGEX_PRECEDING_KEYWORDS = new Set([
"return",
"typeof",
"instanceof",
"in",
"of",
"new",
"delete",
"void",
"do",
"else",
"yield",
"await",
"case"
]);
function opensRegex(src, i) {
let j = i - 1;
while (j >= 0 && (src[j] === " " || src[j] === "\t" || src[j] === "\r" || src[j] === `
`))
j--;
if (j < 0)
return true;
const prev = src[j];
if (/[A-Za-z0-9_$]/.test(prev)) {
let k = j;
while (k >= 0 && /[A-Za-z0-9_$]/.test(src[k]))
k--;
return REGEX_PRECEDING_KEYWORDS.has(src.slice(k + 1, j + 1));
}
return prev !== ")" && prev !== "]" && prev !== "." && prev !== '"' && prev !== "'" && prev !== "`";
}
function skipRegex(src, i) {
let j = i + 1;
let inClass = false;
while (j < src.length) {
const c = src[j];
if (c === `
`)
return null;
if (c === "\\") {
j += 2;
continue;
}
if (inClass) {
if (c === "]")
inClass = false;
} else if (c === "[") {
inClass = true;
} else if (c === "/") {
j++;
while (j < src.length && /[a-z]/.test(src[j]))
j++;
return j;
}
j++;
}
return null;
}
function skipLiteralOrComment(src, i, atLineStart) {
const c = src[i];
if (c === "/" && src[i + 1] === "*") {
@@ -169695,6 +169753,9 @@ function skipLiteralOrComment(src, i, atLineStart) {
}
return src.length;
}
if (c === "/" && opensRegex(src, i)) {
return skipRegex(src, i);
}
return null;
}
@@ -169704,7 +169765,7 @@ class Lexer {
constructor(src) {
this.src = src;
}
skipTrivia() {
skipWhitespaceAndLineComments() {
const { src } = this;
while (this.pos < src.length) {
const c = src[this.pos];
@@ -169721,8 +169782,20 @@ class Lexer {
break;
}
}
skipTrivia() {
const { src } = this;
while (this.pos < src.length) {
this.skipWhitespaceAndLineComments();
if (src[this.pos] === "/" && src[this.pos + 1] === "*") {
const close = src.indexOf("*/", this.pos + 2);
this.pos = close === -1 ? src.length : close + 2;
continue;
}
break;
}
}
startsWithBlockComment() {
this.skipTrivia();
this.skipWhitespaceAndLineComments();
return this.src[this.pos] === "/" && this.src[this.pos + 1] === "*";
}
next() {
@@ -171538,6 +171611,25 @@ function parse(source) {
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
}
switch (kw.value) {
case "auth": {
lx.next();
expect("eq");
const value = lx.next();
if (value.type !== "ident" || !["true", "false"].includes(value.value)) {
throw new ParseError(`Expected auth = true or false at offset ${value.pos}`);
}
security.auth = value.value === "true" ? "required" : "false";
break;
}
case "permission": {
lx.next();
expect("eq");
const value = expect("string").value.trim();
if (!value)
throw new ParseError(`Page permission cannot be empty at offset ${kw.pos}`);
security.permission = value;
break;
}
case "layout": {
lx.next();
expect("eq");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.8.14",
"version": "0.8.15",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
+23 -1
View File
@@ -266,7 +266,29 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (options.secret.length < MIN_SECRET_LENGTH) {
throw new TypeError(`auth secret must be at least ${MIN_SECRET_LENGTH} characters`);
}
const store = options.store;
let resolvedStore: AuthStore | undefined;
const resolveStore = (): AuthStore => {
if (resolvedStore) return resolvedStore;
resolvedStore = typeof options.store === "function" ? options.store() : options.store;
if (!resolvedStore || typeof resolvedStore !== "object") {
throw new TypeError("WRN-AUTH-STORE: the auth store factory did not return an AuthStore");
}
return resolvedStore;
};
// AuthEngine.store remains source-compatible while deferring the factory
// until the first actual property read or method call.
const store = new Proxy({} as AuthStore, {
get(_target, property) {
const value = Reflect.get(resolveStore() as object, property);
return typeof value === "function" ? value.bind(resolveStore()) : value;
},
set(_target, property, value) {
return Reflect.set(resolveStore() as object, property, value);
},
has(_target, property) {
return Reflect.has(resolveStore() as object, property);
},
});
const now = () => {
const value = options.clock?.now() ?? Date.now();
if (!Number.isFinite(value)) throw new Error("WRN-AUTH-CLOCK: clock returned an invalid time");
+94 -2
View File
@@ -10,6 +10,8 @@ import {
} from "../middleware.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
import type { AuthSessionVerificationHandler } from "../types.ts";
import { completeAuth, startAuth, type OAuthProvider } from "@wrnexus/oauth";
import { assignDefaultAuthzRoles } from "@wrnexus/authz";
function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
@@ -50,6 +52,8 @@ export interface AuthHttpOptions {
schemas?: AuthSchemaOverrides | AuthSchemaSet;
passkey?: AuthPasskeyHttpOptions;
onSessionVerification?: AuthSessionVerificationHandler;
oauth?: Record<string, OAuthProvider>;
oauthMfaPath?: string;
}
export function createAuthHttpHandlers(options: AuthHttpOptions) {
@@ -59,6 +63,17 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const onSignedOut = engine.onSignedOut;
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
function oauthProvider(ctx: Context): OAuthProvider | undefined {
return options.oauth?.[String(ctx.params.provider ?? "").toLowerCase()];
}
function oauthRedirectUri(ctx: Context, provider: OAuthProvider): string {
return new URL(
`/api/auth/oauth/${encodeURIComponent(provider.name)}/callback`,
options.baseUrl ?? ctx.url.origin,
).toString();
}
function signupRedirect(ctx: Context, value: string | undefined, fallback: string): Response {
const path = safeAuthReturnTo(value, ctx.url.origin) ?? fallback;
return Response.redirect(new URL(path, ctx.url), 303);
@@ -80,6 +95,71 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
}
return {
async oauthProviders(ctx: Context): Promise<Response> {
return json({
ok: true,
providers: Object.values(options.oauth ?? {}).map((provider) => ({
id: provider.name,
name: provider.name,
label: `Continue with ${provider.name.charAt(0).toUpperCase()}${provider.name.slice(1)}`,
href: `/api/auth/oauth/${encodeURIComponent(provider.name)}?returnTo=${encodeURIComponent(
safeAuthReturnTo(ctx.url.searchParams.get("returnTo") ?? undefined, ctx.url.origin) ??
"/",
)}`,
})),
});
},
async startOAuth(ctx: Context): Promise<Response> {
const provider = oauthProvider(ctx);
if (!provider) return json({ ok: false, error: "OAuth provider not configured" }, 404);
const returnTo =
safeAuthReturnTo(ctx.url.searchParams.get("returnTo") ?? undefined, ctx.url.origin) ?? "/";
const started = await startAuth(provider, { redirectUri: oauthRedirectUri(ctx, provider) });
ctx.cookies.transaction(`wrnexus.oauth.${provider.name}`).set({
state: started.state,
verifier: started.verifier,
returnTo,
});
return Response.redirect(started.url, 302);
},
async completeOAuth(ctx: Context): Promise<Response> {
const provider = oauthProvider(ctx);
if (!provider) return json({ ok: false, error: "OAuth provider not configured" }, 404);
const transaction = ctx.cookies
.transaction<{
state: string;
verifier: string;
returnTo: string;
}>(`wrnexus.oauth.${provider.name}`)
.consume();
const state = ctx.url.searchParams.get("state");
const code = ctx.url.searchParams.get("code");
if (!transaction || !state || transaction.state !== state || !code) {
return json({ ok: false, error: "OAuth transaction is invalid or expired" }, 400);
}
const completed = await completeAuth(provider, {
code,
verifier: transaction.verifier,
redirectUri: oauthRedirectUri(ctx, provider),
});
const result = await engine.loginWithOAuth(
provider.name,
completed.profile,
completed.tokens,
);
if (result.code === "mfa-required" && result.mfaToken) {
ctx.cookies.transaction("wrnexus.auth.mfa", { sameSite: "Lax", maxAge: 300 }).set({
mfaToken: result.mfaToken,
returnTo: transaction.returnTo,
});
return Response.redirect(new URL(options.oauthMfaPath ?? "/two-factor", ctx.url), 303);
}
if (!result.ok || !result.session || !result.user) return json(result, 401);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) return onSignedIn(ctx, transaction.returnTo);
return Response.redirect(new URL(transaction.returnTo, ctx.url), 303);
},
async verifySession(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (options.onSessionVerification) {
@@ -110,6 +190,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
});
if (!result.ok || !result.user) return json(result, 400);
await assignDefaultAuthzRoles(result.user.id, "signup");
const action = await onSuccessfulSignUp?.(ctx, result.user);
if (action instanceof Response) return action;
if (action?.autoSignIn) {
@@ -230,6 +312,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
password: text(input.password) || undefined,
displayName: text(input.displayName) || undefined,
});
if (result.ok && result.user) await assignDefaultAuthzRoles(result.user.id, "invitation");
return json(result, result.ok ? 200 : 400);
},
@@ -403,12 +486,18 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const validation = await parseBody(schemas.mfaComplete, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const continuation = ctx.cookies
.transaction<{ mfaToken: string; returnTo?: string }>("wrnexus.auth.mfa", {
sameSite: "Lax",
maxAge: 300,
})
.consume();
const methodValue = text(input.method);
const method = ["totp", "recovery-code", "email-otp", "sms-otp"].includes(methodValue)
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
: "totp";
const result = await engine.completeMfa({
mfaToken: text(input.mfaToken),
mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
method,
code: text(input.code),
challengeId: text(input.challengeId) || undefined,
@@ -421,7 +510,10 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
return onSignedIn(
ctx,
safeAuthReturnTo(text(input.returnTo) || continuation?.returnTo, ctx.url.origin),
);
}
return json(result);
},
+3
View File
@@ -9,6 +9,8 @@ export {
clearAuthSession,
getAuthUser,
getAuthSession,
requireAuthUser,
AuthRequiredError,
isAuthenticatedContext,
AUTH_SESSION_KEY,
} from "./middleware.ts";
@@ -20,6 +22,7 @@ export {
export {
authPlugin,
authComponentsDir,
validateProductionAuthConfig,
type AuthConfig,
type AuthRoutesConfig,
type AuthPluginOptions,
+17
View File
@@ -79,6 +79,23 @@ export function getAuthUser(ctx: Context): AuthPublicUser | null {
);
}
/** Return a typed authenticated user or fail closed for direct handler use. */
export function requireAuthUser(ctx: Context): AuthPublicUser {
const user = getAuthUser(ctx);
if (!user) throw new AuthRequiredError();
return user;
}
export class AuthRequiredError extends Error {
readonly code = "WRN-AUTH-REQUIRED";
readonly status = 401;
constructor() {
super("Authentication is required");
this.name = "AuthRequiredError";
}
}
export function getAuthSession(ctx: Context): AuthSession | null {
return (ctx.locals.authSession as AuthSession | undefined) ?? null;
}
+69
View File
@@ -2,6 +2,13 @@ import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import {
discord,
github,
google,
type OAuthProvider,
type ProviderCredentials,
} from "@wrnexus/oauth";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSessionVerificationHandler } from "./types.ts";
@@ -32,8 +39,11 @@ export interface AuthRoutesConfig {
sessions?: boolean;
impersonation?: boolean;
passkeys?: boolean;
oauth?: boolean;
}
export type AuthOAuthProviderConfig = OAuthProvider | ProviderCredentials;
export interface AuthConfig {
enabled?: boolean;
engine?: AuthEngine;
@@ -48,12 +58,36 @@ export interface AuthConfig {
baseUrl?: string;
csrf?: boolean;
passkey?: AuthPasskeyHttpOptions;
oauth?: Partial<Record<"google" | "github" | "discord", AuthOAuthProviderConfig>> &
Record<string, AuthOAuthProviderConfig>;
oauthMfaPath?: string;
/** Disable only when an external deployment gate performs equivalent checks. */
productionValidation?: boolean;
/** Shared SSO cookie whose value is an AuthEngine session id. */
sessionCookieName?: string;
/** Customize the package forward-auth verification response. */
onSessionVerification?: AuthSessionVerificationHandler;
}
export function validateProductionAuthConfig(
config: Pick<AuthConfig, "baseUrl" | "oauth">,
env: Record<string, string | undefined> = process.env,
): void {
const secret = env.AUTH_SECRET?.trim() ?? "";
if (secret.length < 32 || /^(?:change-me|development|test-only)/i.test(secret)) {
throw new Error(
"WRN-AUTH-CONFIG: AUTH_SECRET must be a non-development secret of at least 32 characters in production",
);
}
if (!config.baseUrl) throw new Error("WRN-AUTH-CONFIG: auth.baseUrl is required in production");
const base = new URL(config.baseUrl);
if (base.protocol !== "https:")
throw new Error("WRN-AUTH-CONFIG: auth.baseUrl must use HTTPS in production");
if (["localhost", "127.0.0.1", "::1"].includes(base.hostname)) {
throw new Error("WRN-AUTH-CONFIG: auth.baseUrl must not use localhost in production");
}
}
/** Explicit plugin options remain supported for compatibility. Prefer config.auth. */
export interface AuthPluginOptions {
componentDir?: string;
@@ -88,6 +122,8 @@ interface ResolvedAuthConfig {
passkey?: AuthPasskeyHttpOptions;
sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler;
oauth: Record<string, OAuthProvider>;
oauthMfaPath?: string;
}
const moduleRoot = dirname(fileURLToPath(import.meta.url));
@@ -121,6 +157,24 @@ function resolveConfig(
const enabled = raw.enabled !== false;
const hasEngine = Boolean(raw.engine);
const hasDefaultDb = Boolean(config.db);
const oauth: Record<string, OAuthProvider> = {};
for (const [name, provider] of Object.entries(raw.oauth ?? {})) {
if (!provider || !provider.clientId?.trim() || !provider.clientSecret?.trim()) continue;
oauth[name] =
"authorizeUrl" in provider
? provider
: name === "google"
? google(provider)
: name === "github"
? github(provider)
: name === "discord"
? discord(provider)
: (() => {
throw new Error(
`WRN-AUTH-OAUTH-CONFIG: custom provider '${name}' requires a complete OAuthProvider`,
);
})();
}
return {
enabled,
@@ -148,6 +202,8 @@ function resolveConfig(
passkey: raw.passkey,
sessionCookieName: raw.sessionCookieName,
onSessionVerification: raw.onSessionVerification,
oauth,
oauthMfaPath: raw.oauthMfaPath,
};
}
@@ -176,6 +232,7 @@ function fallbackConfig(options: AuthPluginOptions): ResolvedAuthConfig {
schemas: resolveAuthSchemas(),
csrf: true,
oauth: {},
};
}
@@ -353,6 +410,16 @@ export function authPlugin(options: AuthPluginOptions = {}) {
},
configure(config, context) {
const raw = (config.auth ?? {}) as AuthConfig;
if (
context.mode === "production" &&
process.env.NODE_ENV === "production" &&
raw.enabled !== false &&
raw.engine &&
raw.productionValidation !== false
) {
validateProductionAuthConfig(raw);
}
const value = resolveConfig(config, options);
context.metadata.set(resolvedConfigKey, value);
@@ -383,6 +450,8 @@ export function authPlugin(options: AuthPluginOptions = {}) {
sessionCookieName: value.sessionCookieName,
onSessionVerification: value.onSessionVerification,
oauth: value.oauth,
oauthMfaPath: value.oauthMfaPath,
});
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir);
+2
View File
@@ -61,6 +61,8 @@ function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
baseUrl: routeOptions.baseUrl ?? ctx.url.origin,
passkey: routeOptions.passkey,
onSessionVerification: routeOptions.onSessionVerification,
oauth: routeOptions.oauth,
oauthMfaPath: routeOptions.oauthMfaPath,
});
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("completeOAuth", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("startOAuth", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("oauthProviders", ctx);
}
+20 -1
View File
@@ -12,7 +12,8 @@ export type AuthRouteGroup =
| "mfa"
| "sessions"
| "impersonation"
| "passkeys";
| "passkeys"
| "oauth";
export interface AuthRouteDefinition {
path: string;
@@ -23,6 +24,24 @@ export interface AuthRouteDefinition {
/** Single source of truth for package-contributed auth endpoints. */
export const AUTH_ROUTE_DEFINITIONS = [
{
path: "/api/auth/oauth/providers",
group: "oauth",
handler: "oauthProviders",
methods: ["GET"],
},
{
path: "/api/auth/oauth/[provider]",
group: "oauth",
handler: "startOAuth",
methods: ["GET"],
},
{
path: "/api/auth/oauth/[provider]/callback",
group: "oauth",
handler: "completeOAuth",
methods: ["GET"],
},
{
path: "/api/auth/session/verify",
group: "sessions",
+3
View File
@@ -1,6 +1,7 @@
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSessionVerificationHandler } from "./types.ts";
import type { OAuthProvider } from "@wrnexus/oauth";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "./validation.ts";
export interface DefaultAuthRouteOptions {
@@ -9,6 +10,8 @@ export interface DefaultAuthRouteOptions {
passkey?: AuthPasskeyHttpOptions;
sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler;
oauth?: Record<string, OAuthProvider>;
oauthMfaPath?: string;
}
interface AuthRuntimeState {
+7 -2
View File
@@ -403,7 +403,12 @@ export interface AuthRandom {
}
export interface AuthEngineOptions {
store: import("./store.ts").AuthStore;
/**
* Authentication storage may be created lazily. This is the preferred form
* for database-backed stores because application configuration is imported
* before the runtime database connection is installed.
*/
store: import("./store.ts").AuthStore | (() => import("./store.ts").AuthStore);
secret: string;
issuer?: string;
delivery?: AuthDeliveryProvider;
@@ -501,7 +506,7 @@ export interface AuthResult {
};
}
export interface AuthenticatedContext extends Context {
export interface AuthenticatedContext extends Context<Record<string, string>, AuthPublicUser> {
user: AuthPublicUser;
locals: Context["locals"] & {
authUser: AuthPublicUser;
+1 -5
View File
@@ -160,11 +160,7 @@ export const mfaOtpRequestSchema = v.object({
});
export const mfaSchema = v.object({
mfaToken: v
.string()
.required("MFA transaction is missing")
.min(20, "MFA transaction is invalid")
.max(512),
mfaToken: v.string().min(20, "MFA transaction is invalid").max(512).optional(),
method: v
.string()
.required("Choose a verification method")
+19
View File
@@ -4,6 +4,25 @@ import { MemoryAuthStore } from "../src/stores/memory.ts";
import { generateTotp } from "../src/totp/index.ts";
import type { AuthDeliveryMessage, PasskeyProvider } from "../src/types.ts";
test("authentication storage factories resolve lazily and only once", async () => {
let calls = 0;
const backing = new MemoryAuthStore();
const engine = createAuthEngine({
store: () => {
calls++;
return backing;
},
secret: "a secure test secret that is longer than thirty-two characters",
});
expect(calls).toBe(0);
expect(engine.store).toBeDefined();
expect(calls).toBe(0);
await engine.getUser("missing");
await engine.getUser("still-missing");
expect(calls).toBe(1);
});
function fixture() {
let time = 1_720_000_000_000;
let seed = 11;
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, expect, test } from "bun:test";
import { createContext, withContextHeaders } from "@wrnexus/core";
import type { OAuthProvider } from "@wrnexus/oauth";
import { createAuthEngine } from "../src/engine.ts";
import { createAuthHttpHandlers } from "../src/http/index.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
test("configured OAuth owns PKCE state, callback, and session establishment", async () => {
const provider: OAuthProvider = {
name: "example",
authorizeUrl: "https://identity.example/authorize",
tokenUrl: "https://identity.example/token",
userInfoUrl: "https://identity.example/user",
scopes: ["openid", "email"],
clientId: "client",
clientSecret: "secret",
mapProfile: (raw) => ({
id: String(raw.id),
email: String(raw.email),
raw,
}),
};
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "oauth-http-secret-that-is-longer-than-thirty-two-characters",
});
const handlers = createAuthHttpHandlers({
engine,
baseUrl: "https://app.example",
oauth: { example: provider },
});
const startRequest = new Request(
"https://app.example/api/auth/oauth/example?returnTo=%2Fdashboard",
);
const startCtx = createContext(startRequest, new URL(startRequest.url));
startCtx.params = { provider: "example" };
const start = await handlers.startOAuth(startCtx);
expect(start.status).toBe(302);
const location = new URL(start.headers.get("location")!);
expect(location.searchParams.get("code_challenge_method")).toBe("S256");
const issued = withContextHeaders(startCtx, start).headers.get("set-cookie")!;
const replies = [
Response.json({ access_token: "access", token_type: "Bearer" }),
Response.json({ id: "provider-user", email: "oauth@example.test" }),
];
const mock: typeof fetch = Object.assign(async () => replies.shift()!, {
preconnect: () => undefined,
});
globalThis.fetch = mock;
const callbackRequest = new Request(
`https://app.example/api/auth/oauth/example/callback?code=code&state=${encodeURIComponent(location.searchParams.get("state")!)}`,
{ headers: { cookie: issued.split(";", 1)[0]! } },
);
const callbackCtx = createContext(callbackRequest, new URL(callbackRequest.url));
callbackCtx.params = { provider: "example" };
const callback = await handlers.completeOAuth(callbackCtx);
expect(callback.status).toBe(303);
expect(callback.headers.get("location")).toBe("https://app.example/dashboard");
expect(callbackCtx.user).toMatchObject({ id: expect.any(String) });
});
+16 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { createPluginRunner } from "@wrnexus/plugin";
import { authPlugin } from "../src/plugin.ts";
import { authPlugin, validateProductionAuthConfig } from "../src/plugin.ts";
import { AUTH_ROUTE_DEFINITIONS } from "../src/routes/definitions.ts";
import { readFileSync } from "node:fs";
import { createAuthEngine } from "../src/engine.ts";
@@ -65,6 +65,21 @@ test("plugin contributes components, runtime, styles, migration, and toolbar", a
expect(contributions.middleware).toHaveLength(1);
});
test("production auth validation rejects development secrets and origins", () => {
expect(() =>
validateProductionAuthConfig(
{ baseUrl: "http://localhost:3000" },
{ AUTH_SECRET: "change-me" },
),
).toThrow("AUTH_SECRET");
expect(() =>
validateProductionAuthConfig(
{ baseUrl: "http://app.example" },
{ AUTH_SECRET: "a-production-secret-that-is-longer-than-thirty-two-characters" },
),
).toThrow("HTTPS");
});
test("unconfigured automatic discovery fails closed for routes, middleware, and migrations", async () => {
const metadata = new Map<string, unknown>();
const runner = createPluginRunner(authPlugin(), {
+12 -3
View File
@@ -1,15 +1,24 @@
{
"name": "@wrnexus/authz",
"version": "0.8.9",
"version": "0.8.10",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./db": "./src/db.ts"
"./db": "./src/db.ts",
"./plugin": "./src/plugin.ts"
},
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/db": "workspace:*"
"@wrnexus/db": "workspace:*",
"@wrnexus/plugin": "workspace:*"
},
"wrnexus": {
"plugin": {
"plugin": "./src/plugin.ts",
"export": "default",
"factory": true
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { DecisionPolicy } from "./advanced.ts";
export interface CatalogSource {
/** File or package that declared this module, used in conflict messages. */
source: string;
module: AuthzModule;
module: AuthzModule<never, never>;
}
/** Structural equality for declaration metadata. Key order is irrelevant. */
+40
View File
@@ -0,0 +1,40 @@
import { getDb } from "@wrnexus/db";
import { dbPermissionStore } from "./db.ts";
import type { PermissionStore } from "./store.ts";
export type AuthzIdentityEvent = "signup" | "invitation";
interface DefaultRoleState {
signup: string[];
invitation: string[];
store?: PermissionStore;
}
const key = Symbol.for("@wrnexus/authz:default-roles:v1");
function state(): DefaultRoleState {
const global = globalThis as Record<PropertyKey, unknown>;
return (global[key] ??= { signup: [], invitation: [] }) as DefaultRoleState;
}
export function setDefaultAuthzRoles(roles: Partial<DefaultRoleState>): void {
state().signup = [...(roles.signup ?? [])];
state().invitation = [...(roles.invitation ?? [])];
}
/** Bind lifecycle role assignment to the same store used by authorization middleware. */
export function setDefaultAuthzRoleStore(store: PermissionStore): void {
state().store = store;
}
/** Assign configured identity lifecycle roles through the framework-owned store. */
export async function assignDefaultAuthzRoles(
subjectId: string,
event: AuthzIdentityEvent,
): Promise<void> {
if (!subjectId) return;
const roles = state()[event];
if (!roles.length) return;
const store = state().store ?? dbPermissionStore(getDb());
await Promise.all(roles.map((role) => store.assignRole(subjectId, role)));
}
+8 -1
View File
@@ -157,8 +157,11 @@ export {
guardPermission,
filterCan,
AUTHZ_LOCALS_KEY,
getRequestAuthorization,
} from "./middleware.ts";
export type { GuardOptions } from "./middleware.ts";
export type { GuardOptions, AuthorizationResponses, RequestAuthorization } from "./middleware.ts";
export { defineAuthorizedHandler, defineOwnedResource } from "./resource.ts";
export type { AuthorizedHandlerOptions, OwnedResourceDefinition } from "./resource.ts";
export type {
AuthzScope,
AuthzCatalog,
@@ -169,3 +172,7 @@ export type {
} from "./types.ts";
export type { AuthorizeDecisionOptions } from "./advanced.ts";
export { generatePermissionTypes } from "./codegen.ts";
export { authzPlugin } from "./plugin.ts";
export type { AuthzConfig } from "./plugin.ts";
export { assignDefaultAuthzRoles, setDefaultAuthzRoles } from "./defaults.ts";
export type { AuthzIdentityEvent } from "./defaults.ts";
+52 -10
View File
@@ -10,6 +10,27 @@ import type { AuthzScope } from "./types.ts";
*/
export const AUTHZ_LOCALS_KEY = "_authz";
export interface AuthorizationResponses {
forbidden(decision?: AuthorizationDecision, options?: { exposeReason?: boolean }): Response;
notFoundOrForbidden(options?: {
mayDiscover?: boolean;
decision?: AuthorizationDecision;
exposeReason?: boolean;
}): Response;
}
export interface RequestAuthorization extends AuthorizationResponses {
can(permission: string, resource?: unknown): Promise<boolean>;
decide(permission: string, resource?: unknown): Promise<AuthorizationDecision>;
}
declare module "@wrnexus/core" {
interface Context {
/** Installed by authzMiddleware for handlers that prefer context-local authorization. */
authz?: RequestAuthorization;
}
}
interface RequestAuthz {
resolver: AuthzResolver;
/**
@@ -49,10 +70,39 @@ export function authzMiddleware(options: AuthzResolverOptions): Middleware {
byValue: new Map(),
};
ctx.locals[AUTHZ_LOCALS_KEY] = request;
ctx.authz = {
can: (permission, resource) => can(ctx, permission, resource),
decide: (permission, resource) => decideFor(ctx, permission, resource),
forbidden: (decision, responseOptions) =>
forbiddenResponse(decision, responseOptions?.exposeReason),
notFoundOrForbidden: (responseOptions = {}) =>
responseOptions.mayDiscover
? Response.json(
{ ok: false, error: "Not Found" },
{ status: 404, headers: NO_STORE_HEADERS },
)
: forbiddenResponse(responseOptions.decision, responseOptions.exposeReason),
};
return next();
};
}
function forbiddenResponse(decision?: AuthorizationDecision, exposeReason = false): Response {
return Response.json(
exposeReason
? { ok: false, error: "Forbidden", reason: decision?.reason, policy: decision?.policy }
: { ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
}
export function getRequestAuthorization(ctx: Context): RequestAuthorization {
if (!ctx.authz) {
throw new Error("WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request.");
}
return ctx.authz;
}
/**
* Read the tenant from the context at decision time, not at middleware time:
* a request that switches tenant mid-flight must not keep the old scope.
@@ -236,10 +286,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
reason: "Resource unavailable",
at: Date.now(),
});
return Response.json(
{ ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
return forbiddenResponse();
}
}
@@ -264,12 +311,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
);
}
return Response.json(
options.exposeReason
? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
: { ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
return forbiddenResponse(result, options.exposeReason);
};
}
+97
View File
@@ -0,0 +1,97 @@
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import { authzMigrationSql } from "./migrations.ts";
import { setDefaultAuthzRoles } from "./defaults.ts";
export interface AuthzConfig {
enabled?: boolean;
declarations?: string;
store?: "database" | "memory";
migrations?: boolean;
middleware?: boolean;
strict?: boolean;
defaultRoles?: {
signup?: string[];
invitation?: string[];
};
}
interface ResolvedAuthzConfig {
enabled: boolean;
store: "database" | "memory";
migrations: boolean;
middleware: boolean;
strict: boolean;
defaultRoles: { signup: string[]; invitation: string[] };
driver: "sqlite" | "postgres" | "mysql";
}
const moduleRoot = dirname(fileURLToPath(import.meta.url));
const runtimeExtension = basename(moduleRoot) === "dist" ? ".js" : ".ts";
const middlewareEntry = join(moduleRoot, `runtime-middleware${runtimeExtension}`);
const memoryMiddlewareEntry = join(moduleRoot, `runtime-memory-middleware${runtimeExtension}`);
const metadataKey = "@wrnexus/authz:config";
function resolved(context: PluginContext): ResolvedAuthzConfig {
return (
(context.metadata.get(metadataKey) as ResolvedAuthzConfig | undefined) ?? {
enabled: false,
store: "database",
migrations: false,
middleware: false,
strict: true,
defaultRoles: { signup: [], invitation: [] },
driver: "sqlite",
}
);
}
export function authzPlugin() {
return definePlugin({
name: "@wrnexus/authz",
version: "0.8.0",
after: ["@wrnexus/auth"],
configure(config, context) {
const raw = (config.authz ?? {}) as AuthzConfig;
const enabled = raw.enabled !== false && Boolean(config.authz);
const db = config.db as { driver?: ResolvedAuthzConfig["driver"] } | undefined;
const value: ResolvedAuthzConfig = {
enabled,
store: raw.store ?? "database",
migrations: raw.migrations ?? enabled,
middleware: raw.middleware ?? enabled,
strict: raw.strict ?? true,
defaultRoles: {
signup: [...(raw.defaultRoles?.signup ?? [])],
invitation: [...(raw.defaultRoles?.invitation ?? [])],
},
driver: db?.driver ?? "sqlite",
};
if (enabled && value.store === "database" && !config.db) {
throw new Error("WRN-AUTHZ-CONFIG: authz.store='database' requires config.db");
}
context.metadata.set(metadataKey, value);
setDefaultAuthzRoles(value.defaultRoles);
config.authz = { ...raw, ...value };
},
middleware(context) {
const value = resolved(context);
if (!value.enabled || !value.middleware) return [];
return [value.store === "database" ? middlewareEntry : memoryMiddlewareEntry];
},
migrations(context) {
const value = resolved(context);
if (!value.enabled || !value.migrations || value.store !== "database") return [];
const sql = authzMigrationSql(value.driver);
return [
{
id: "wrnexus-authz-001",
source: `-- +up\n${sql.up.join(";\n")}\n;\n-- +down\n${sql.down.join(";\n")}\n;`,
},
];
},
});
}
export default authzPlugin;
+4 -2
View File
@@ -1,4 +1,4 @@
import type { AuthzModule } from "./types.ts";
import type { AuthzModule, DefinedAuthzModule } from "./types.ts";
const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
@@ -6,7 +6,9 @@ const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
* Validate and freeze one authorization declaration. Called from
* `app/authz/<name>.ts` as the module's default export.
*/
export function defineAuthz(module: AuthzModule): AuthzModule {
export function defineAuthz<Subject = any, Resource = any>(
module: AuthzModule<Subject, Resource>,
): DefinedAuthzModule<Subject, Resource> {
const permissions = module.permissions ?? {};
const roles = module.roles ?? {};
const policies = module.policies ?? {};
+59
View File
@@ -0,0 +1,59 @@
import type { Context } from "@wrnexus/core";
import { decideFor } from "./middleware.ts";
export interface AuthorizedHandlerOptions<Resource, Result extends Response = Response> {
permission: string;
resource?: (ctx: Context) => Resource | null | undefined | Promise<Resource | null | undefined>;
exposeReason?: boolean;
mayDiscover?: (ctx: Context) => boolean | Promise<boolean>;
handle(ctx: Context & { resource: Resource }): Result | Promise<Result>;
}
/** Define an API handler whose resource loading and permission check cannot be skipped. */
export function defineAuthorizedHandler<Resource = undefined, Result extends Response = Response>(
options: AuthorizedHandlerOptions<Resource, Result>,
): (ctx: Context) => Promise<Response> {
return async (ctx) => {
if (!ctx.user) return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const resource = options.resource ? await options.resource(ctx) : (undefined as Resource);
if (options.resource && resource == null) {
const mayDiscover = await options.mayDiscover?.(ctx);
return mayDiscover
? Response.json({ ok: false, error: "Not Found" }, { status: 404 })
: Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const decision = await decideFor(ctx, options.permission, resource);
if (!decision.allowed) {
return ctx.authz!.forbidden(decision, { exposeReason: options.exposeReason });
}
ctx.resource = resource;
return options.handle(ctx as Context & { resource: Resource });
};
}
export interface OwnedResourceDefinition<Resource> {
name: string;
owner(resource: Resource): string | null | undefined;
permissions: {
read?: string;
create?: string;
update?: string;
delete?: string;
readAny?: string;
writeAny?: string;
};
}
/** Shared ownership and override vocabulary for application resource modules. */
export function defineOwnedResource<Resource>(definition: OwnedResourceDefinition<Resource>) {
return Object.freeze({
...definition,
attributes(resource: Resource) {
return { ownerId: definition.owner(resource) };
},
isOwner(subjectId: string, resource: Resource) {
const ownerId = definition.owner(resource);
return Boolean(subjectId && ownerId && subjectId === ownerId);
},
});
}
@@ -0,0 +1,12 @@
import { getAuthzCatalog } from "./client.ts";
import { setDefaultAuthzRoleStore } from "./defaults.ts";
import { authzMiddleware } from "./middleware.ts";
import { memoryPermissionStore } from "./store.ts";
/** Process-local authorization storage for development and stateless tests. */
const store = memoryPermissionStore();
setDefaultAuthzRoleStore(store);
export default function frameworkMemoryAuthz(ctx: Context, next: Next) {
return authzMiddleware({ catalog: getAuthzCatalog(), store, strict: true })(ctx, next);
}
import type { Context, Next } from "@wrnexus/core";
+31
View File
@@ -0,0 +1,31 @@
import type { Context, Next } from "@wrnexus/core";
import { getDb, type Db } from "@wrnexus/db";
import { getAuthzCatalog } from "./client.ts";
import { dbPermissionStore, ensureAuthzTables } from "./db.ts";
import { setDefaultAuthzRoleStore } from "./defaults.ts";
import { authzMiddleware } from "./middleware.ts";
const initialized = new WeakMap<Db, Promise<void>>();
function initialize(db: Db): Promise<void> {
let pending = initialized.get(db);
if (!pending) {
pending = ensureAuthzTables(db);
initialized.set(db, pending);
pending.catch(() => initialized.delete(db));
}
return pending;
}
/** Package-installed authorization middleware with lazy database resolution. */
export default async function frameworkAuthz(ctx: Context, next: Next): Promise<Response> {
const db = getDb();
await initialize(db);
const store = dbPermissionStore(db);
setDefaultAuthzRoleStore(store);
return authzMiddleware({
catalog: getAuthzCatalog(),
store,
strict: true,
})(ctx, next);
}
+11 -2
View File
@@ -18,15 +18,24 @@ export interface AttributeMeta {
}
/** One `app/authz/<name>.ts` declaration. */
export interface AuthzModule {
export interface AuthzModule<Subject = any, Resource = any> {
permissions?: Record<string, PermissionMeta>;
roles?: Record<string, string[]>;
policies?: Record<string, DecisionPolicy<never, never>>;
policies?: Record<string, DecisionPolicy<Subject, Resource>>;
attributes?: Record<string, AttributeMeta>;
/** permission id -> policy names that must pass for it. */
bindings?: Record<string, string[]>;
}
/** A declaration after defineAuthz() has supplied all optional collections. */
export type DefinedAuthzModule<Subject = any, Resource = any> = {
permissions: Record<string, PermissionMeta>;
roles: Record<string, string[]>;
policies: Record<string, DecisionPolicy<Subject, Resource>>;
attributes: Record<string, AttributeMeta>;
bindings: Record<string, string[]>;
};
/** The merged, frozen view of every declaration in the app. */
export interface AuthzCatalog {
permissions: ReadonlyMap<string, PermissionMeta>;
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from "bun:test";
import { createPluginRunner } from "@wrnexus/plugin";
import { authzPlugin } from "../src/plugin.ts";
function runner() {
return createPluginRunner(authzPlugin(), {
root: process.cwd(),
mode: "development",
command: "dev",
metadata: new Map(),
warn() {},
});
}
test("authz plugin is inert until configured", async () => {
const instance = runner();
await instance.configure({});
const contributions = await instance.contributions();
expect(contributions.middleware).toEqual([]);
expect(contributions.migrations).toEqual([]);
});
test("database authz config contributes middleware and dialect migration", async () => {
const instance = runner();
const config: Record<string, unknown> = {
db: { driver: "postgres", url: "postgres://example" },
authz: {
store: "database",
middleware: true,
migrations: true,
defaultRoles: { signup: ["manager"] },
},
};
await instance.configure(config);
const contributions = await instance.contributions();
expect(contributions.middleware).toHaveLength(1);
expect(contributions.migrations).toHaveLength(1);
expect(contributions.migrations[0]?.source).toContain("SERIAL PRIMARY KEY");
expect(config.authz).toMatchObject({ strict: true });
});
test("database authz config refuses a missing database", async () => {
const instance = runner();
await expect(instance.configure({ authz: { store: "database" } })).rejects.toThrow(
"requires config.db",
);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.8.48",
"version": "0.8.49",
"type": "module",
"main": "src/index.ts",
"exports": {
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { runBuild } from "./build.ts";
import { runTypecheck } from "./types.ts";
async function runScript(root: string, name: string): Promise<void> {
const child = Bun.spawn(["bun", "run", name], {
cwd: root,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
const code = await child.exited;
if (code !== 0) throw new Error(`WRN-CHECK: '${name}' failed with exit code ${code}`);
}
/** Canonical generated-artifact-aware application verification pipeline. */
export async function runCheck(appRoot: string): Promise<void> {
const root = resolve(appRoot);
const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as {
scripts?: Record<string, string>;
};
await runBuild(root);
if (!(await runTypecheck(root))) throw new Error("WRN-CHECK: application typecheck failed");
for (const name of ["lint", "test", "format:check"] as const) {
if (manifest.scripts?.[name]) await runScript(root, name);
}
}
+6 -1
View File
@@ -93,7 +93,7 @@ Thumbs.db
"doctor": "wrnexus doctor .",
"analyze": "wrnexus analyze .",
"inspect": "wrnexus inspect packages .",
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
"check": "wrnexus check ."
},
"dependencies": {
"@wrnexus/ai": "${scaffoldFrameworkRange}",
@@ -227,8 +227,13 @@ export default tseslint.config(
dist/
.wrnexus/
**/.wrnexus/
.superpowers/
.claude/
*.log
CLAUDE.md
app/routes.gen.ts
app/types/*.generated.*
app/db/queries.gen.ts
`,
".editorconfig": `root = true
+3 -2
View File
@@ -14,7 +14,7 @@
* (`databases.<name>`), with files under `app/db/<name>/`.
*/
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
@@ -23,6 +23,7 @@ import {
type PackageMigrationDefinition,
} from "@wrnexus/plugin";
import { loadAppConfig, type AppConfig } from "@wrnexus/styles";
import { writeGeneratedFile } from "./write-generated.ts";
import {
generateQueriesFile,
analyzeMigrations,
@@ -156,7 +157,7 @@ export async function regenerateQueries(
.flatMap((f) => parseQueries(readFileSync(join(queriesDir, f), "utf8")));
const refs = await loadModelRefs(dbBase);
const code = generateQueriesFile(queries, refs, dialectOf(driver));
writeFileSync(join(dbBase, "queries.gen.ts"), code, "utf8");
writeGeneratedFile(join(dbBase, "queries.gen.ts"), code);
return queries.length;
}
+9
View File
@@ -60,6 +60,7 @@ Usage:
wrnexus generate types [app-dir] Generate application-wide route/component/key types
wrnexus routes [app-dir] Generate typed named routes
wrnexus typecheck [app-dir] Generate types and check TypeScript plus every .wrn file
wrnexus check [app-dir] Build/generate, typecheck, lint, test, and format-check
wrnexus mobile add <package...> Install Capacitor or Expo native packages
wrnexus mobile compile Compile .wrn pages into native Expo routes
wrnexus native list List cross-platform native capabilities
@@ -239,6 +240,14 @@ async function main(): Promise<void> {
else console.log("✓ Application types are valid");
break;
}
case "check": {
const appRoot = rest.find((arg) => !arg.startsWith("--")) ?? ".";
bootstrapProfile(appRoot, "production", rest);
const { runCheck } = await import("./check.ts");
await runCheck(appRoot);
console.log("✓ Application check passed");
break;
}
case "eject": {
const { runEject } = await import("./eject.ts");
const args = rest.filter((a) => !a.startsWith("--"));
+2 -2
View File
@@ -4,14 +4,14 @@
* startup; also exposed via `wrnexus generate routes`.
*/
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { buildRouter, generateRoutesFile } from "@wrnexus/router";
import { writeGeneratedFile } from "./write-generated.ts";
/** Regenerate `app/routes.gen.ts`. Returns the number of page routes. */
export function regenerateRoutes(appDir: string): number {
const router = buildRouter(appDir);
const code = generateRoutesFile(router.pages);
writeFileSync(join(appDir, "routes.gen.ts"), code, "utf8");
writeGeneratedFile(join(appDir, "routes.gen.ts"), code);
return router.pages.length;
}
+3 -2
View File
@@ -9,6 +9,7 @@ import { generate, generateTargets } from "@wrnexus/compiler";
import { regenerateRoutes } from "./routes.ts";
import { loadAppConfig } from "@wrnexus/styles";
import { createPluginRunner, discoverPlugins, type PluginContributions } from "@wrnexus/plugin";
import { writeGeneratedFile } from "./write-generated.ts";
function files(root: string, predicate: (path: string) => boolean): string[] {
if (!existsSync(root)) return [];
@@ -309,7 +310,7 @@ declare namespace WRNexusGenerated {
`;
mkdirSync(typeDir, { recursive: true });
const output = join(typeDir, "wrnexus.generated.d.ts");
writeFileSync(output, code, "utf8");
writeGeneratedFile(output, code);
// `.d.ts` contents are exempt from checking under `skipLibCheck: true` (set in the
// repo/app tsconfig), so the per-block assertions are written into a real `.ts` file
// instead — only genuine `.ts`/`.tsx` sources are compiled and checked.
@@ -321,7 +322,7 @@ declare namespace WRNexusGenerated {
${apiBlockAssertions(pageAsts, apiContracts, app)}
export {};
`;
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
writeGeneratedFile(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode);
writePluginArtifacts(root, pluginContributions);
return {
file: relative(root, output).replace(/\\/g, "/"),
+11
View File
@@ -0,0 +1,11 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
/** Write generated UTF-8 output only when bytes changed, preserving mtimes on no-op builds. */
export function writeGeneratedFile(path: string, content: string): boolean {
const normalized = content.replace(/\r\n/g, "\n");
if (existsSync(path) && readFileSync(path, "utf8").replace(/\r\n/g, "\n") === normalized) {
return false;
}
writeFileSync(path, normalized, "utf8");
return true;
}
+1 -3
View File
@@ -51,9 +51,7 @@ test("scaffoldApp includes production build and start scripts", () => {
expect(pkg.scripts.production).toBe("bun run build && bun run start");
expect(pkg.scripts.typecheck).toBe("tsc --noEmit");
expect(pkg.scripts.test).toBe("wrnexus test .");
expect(pkg.scripts.check).toBe(
"bun run typecheck && bun run lint && bun run test && bun run format:check",
);
expect(pkg.scripts.check).toBe("wrnexus check .");
} finally {
rmSync(parent, { recursive: true, force: true });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.8.16",
"version": "0.8.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+45 -12
View File
@@ -448,17 +448,17 @@ function bakeLoopAttr(raw: string, typed = false): string {
}
/** Render one loop-body node to template-literal source (nested loops inline). */
function renderLoopBody(node: ViewNode): string {
function renderLoopBody(node: ViewNode, locals: string[] = []): string {
if (node.type === "text") {
return bakeLoopText(node.value);
}
if (node.type === "each") {
return compileEachExpr(node);
return compileEachExpr(node, locals);
}
if (node.type === "if") {
return compileIfExpr(node);
return compileIfExpr(node, locals);
}
const componentTag = isComponentTag(node.tag);
@@ -480,7 +480,16 @@ function renderLoopBody(node: ViewNode): string {
})
.join("");
const inner = node.children.map(renderLoopBody).join("");
// A handler expression is emitted as text and evaluated at event time, so any
// `{#each}` variable it names has to travel with the element. The runtime
// resolves them with closest("[data-wrn-loop-locals]"). Only elements that
// actually bind an event need it -- marking every node would bloat the HTML.
const localsAttr =
locals.length > 0 && node.attrs.some((attr) => attr.event) ? loopLocalsAttr(locals) : "";
const inner = node.children.map((child) => renderLoopBody(child, locals)).join("");
const openAttrs = attrs + localsAttr;
if (node.tag === "Static") return inner;
if (node.tag === "Dynamic")
@@ -536,7 +545,7 @@ function renderLoopBody(node: ViewNode): string {
if (island) return escLit(island);
return (
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs +
openAttrs +
escLit(">") +
inner +
escLit("</div>")
@@ -544,10 +553,20 @@ function renderLoopBody(node: ViewNode): string {
}
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return escLit(`<${node.tag}`) + attrs + escLit(">");
return escLit(`<${node.tag}`) + openAttrs + escLit(">");
}
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`</${node.tag}>`);
return escLit(`<${node.tag}`) + openAttrs + escLit(">") + inner + escLit(`</${node.tag}>`);
}
/**
* Build the ` data-wrn-loop-locals="..."` attribute for a page-rendered loop
* body. Deliberately not passed through escLit: the `${...}` must stay live so
* the values are encoded at render time.
*/
function loopLocalsAttr(locals: string[]): string {
const entries = locals.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ");
return ` data-wrn-loop-locals="\${__wrnexusEncodeLoopLocals({ ${entries} })}"`;
}
/**
@@ -555,11 +574,13 @@ function renderLoopBody(node: ViewNode): string {
* that iterates the (server-evaluated) list and joins the per-item body. `list` is a
* JS expression evaluated where `ssr` data bindings are in scope as raw named values.
*/
function compileEachExpr(node: EachNode): string {
function compileEachExpr(node: EachNode, outerLocals: string[] = []): string {
const item = node.item;
const index = node.index ?? "__wi";
const body = node.body.map(renderLoopBody).join("");
const empty = node.empty.map(renderLoopBody).join("");
// A nested loop can reference the outer loop's variables too.
const locals = [...outerLocals, item, index];
const body = node.body.map((child) => renderLoopBody(child, locals)).join("");
const empty = node.empty.map((child) => renderLoopBody(child, outerLocals)).join("");
return (
"${(() => { const __wl = Array.isArray(" +
node.list +
@@ -582,11 +603,11 @@ function compileEachExpr(node: EachNode): string {
* that renders the first truthy branch's body (or the `{:else}` body, or "" when neither).
* Conditions are JS expressions evaluated in the surrounding server scope.
*/
function compileIfExpr(node: IfNode): string {
function compileIfExpr(node: IfNode, locals: string[] = []): string {
let expr = "``"; // no matching branch → empty string
for (let k = node.branches.length - 1; k >= 0; k--) {
const b = node.branches[k]!;
const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`";
const bodySrc = "`" + b.body.map((child) => renderLoopBody(child, locals)).join("") + "`";
expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr;
}
return "${" + expr + "}";
@@ -1644,6 +1665,18 @@ function generateInner(ast: PageAst): string {
if (needsRuntimeHelpers) {
out.push(`import { buildApiRequest as __wrnexusBuildApiRequest } from "@wrnexus/core";`);
out.push(ssrRuntimeSource());
// Loop bodies that bind an event carry their {#each} locals in an encoded
// attribute. Emitted only when the view actually produced one, so a page
// without handlers in a loop keeps the smaller prelude -- but it MUST be
// emitted whenever the marker is, or the render throws on an undefined
// function instead of the handler throwing on an undefined variable.
if (body.includes("__wrnexusEncodeLoopLocals(")) {
out.push(`function __wrnexusEncodeLoopLocals(value: Record<string, unknown>): string {
const json = JSON.stringify(value);
const buffer = (globalThis as { Buffer?: { from(i: string, e: string): { toString(e: string): string } } }).Buffer;
return buffer ? buffer.from(json, "utf8").toString("base64") : btoa(unescape(encodeURIComponent(json)));
}`);
}
out.push(
`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`,
);
@@ -0,0 +1,120 @@
import { expect, test } from "bun:test";
import { generate, parse } from "../src/index.ts";
// A `{#each}` inside a COMPONENT emitted `data-wrn-loop-locals`, so a handler
// could reference the loop variable. The same view inside a PAGE emitted the
// handler text verbatim with no locals marker, so the loop variable was not
// defined at click time and the handler threw ReferenceError -- with a green
// build and green tests, because nothing renders the page in a browser during
// a build. The runtime resolves locals with closest("[data-wrn-loop-locals]"),
// so emitting the marker is all that is required.
const view = `
{#each packs as pack}
<button @click="buyPack(pack.code)">{pack.code}</button>
{/each}
`;
const body = `
state packs = [{ code: "small" }]
functions { client async function buyPack(c) { console.log(c) } }
view {${view}}
`;
test("a page {#each} exposes loop locals to an event handler", () => {
const code = generate(parse(`page P {${body}}`));
expect(code).toContain("data-wrn-loop-locals");
// The item and the implicit index both have to travel, since a handler may
// reference either.
expect(code).toContain('"pack": pack');
});
test("a component {#each} still exposes loop locals to an event handler", () => {
const code = generate(parse(`component C {${body}}`));
expect(code).toContain("data-wrn-loop-locals");
expect(code).toContain('"pack": pack');
});
test("a named loop index is exposed to a page event handler", () => {
const code = generate(
parse(`page P {
state packs = [{ code: "small" }]
functions { client async function pick(c) { console.log(c) } }
view {
{#each packs as pack, i}
<button @click="pick(i)">{pack.code}</button>
{/each}
}
}`),
);
expect(code).toContain('"i": i');
});
// Only elements that actually bind an event need the marker; adding it to every
// element in a loop body would bloat the HTML for no benefit.
test("a page loop body element with no handler gets no locals marker", () => {
const code = generate(
parse(`page P {
state packs = [{ code: "small" }]
view {
{#each packs as pack}
<span>{pack.code}</span>
{/each}
}
}`),
);
expect(code).not.toContain("data-wrn-loop-locals");
});
// Emitting the marker without defining its encoder would turn a ReferenceError
// on the loop variable into a ReferenceError on the encoder -- at render time
// rather than click time, so strictly worse.
test("a page that emits loop locals also defines the encoder", () => {
const code = generate(parse(`page P {${body}}`));
expect(code).toContain("data-wrn-loop-locals");
expect(code).toContain("function __wrnexusEncodeLoopLocals");
});
test("a page with no loop-local markers does not define the encoder", () => {
const code = generate(
parse(`page P {
state packs = [{ code: "small" }]
view { <span>{packs.length}</span> }
}`),
);
expect(code).not.toContain("__wrnexusEncodeLoopLocals");
});
// The tests above assert the marker is EMITTED. This one executes the generated
// module and asserts it RENDERS with the loop's real values -- generated text
// that reads correctly can still render wrong, and the whole point of the
// marker is what the runtime finds in the DOM at click time.
test("a page loop handler's locals render as real, decodable values", async () => {
const code = generate(
parse(`page Packs {
state packs = [{ code: "small" }, { code: "large" }]
functions { client async function pick(c) { console.log(c) } }
view {
{#each packs as pack}
<button @click="pick(pack.code)">{pack.code}</button>
{/each}
}
}`),
).replace(/^import \{ buildApiRequest[^\n]*\n/m, "");
const js = new Bun.Transpiler({ loader: "ts" }).transformSync(code);
const mod = await import("data:text/javascript;base64," + Buffer.from(js).toString("base64"));
const html: string = await mod.default({
req: new Request("http://x/"),
cookies: {},
session: {},
});
const markers = [...html.matchAll(/data-wrn-loop-locals="([^"]+)"/g)].map((m) => m[1]!);
expect(markers.length).toBe(2);
const decoded = markers.map((m) => JSON.parse(Buffer.from(m, "base64").toString("utf8")));
expect(decoded[0].pack).toEqual({ code: "small" });
expect(decoded[1].pack).toEqual({ code: "large" });
expect(decoded[0].__wi).toBe(0);
expect(decoded[1].__wi).toBe(1);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.8.11",
"version": "0.8.12",
"type": "module",
"main": "src/index.ts",
"exports": {
+41 -6
View File
@@ -22,7 +22,20 @@ import {
/** Translate a key for the active language, interpolating `{param}` placeholders. */
export type TFunction = (key: string, params?: Record<string, string | number>) => string;
export type Context = {
export interface PaginationOptions {
defaultLimit?: number;
maxLimit?: number;
}
export interface PaginationInput {
limit: number;
cursor?: string;
}
export interface Context<
Params extends Record<string, string> = Record<string, string>,
User = unknown,
> {
/** The raw incoming web-standard Request. */
req: Request;
/** Parsed URL of the request (pathname, query, etc.). */
@@ -32,17 +45,19 @@ export type Context = {
/** Translate a key for the active language (identity until the runtime sets it). */
t: TFunction;
/** Dynamic route params, e.g. `/users/[id]` -> `{ id: "42" }`. */
params: Record<string, string>;
params: Params;
/**
* Per-request scratch space. Middleware can attach values here
* (e.g. the authenticated user) and downstream handlers can read them.
*/
locals: Record<string, unknown>;
/** Resource resolved by a declarative route guard, when present. */
resource?: unknown;
/**
* The authenticated user for this request, or null when anonymous. Populated
* by the `sessionAuth` middleware (or `logIn`); read via `getUser(ctx)`.
*/
user?: unknown;
user?: User | null;
/** Active tenant/workspace resolved by tenant middleware. */
tenant?: Tenant;
/** Request tracer installed by observability middleware. */
@@ -59,7 +74,9 @@ export type Context = {
session: SessionStore;
/** Read-only localStorage snapshot sent by the browser for CSR data bindings. */
localStorage: LocalStorageSnapshot;
};
/** Parse and bound the standard `limit` and `cursor` query parameters. */
pagination(options?: PaginationOptions): PaginationInput;
}
/** Calls the next middleware in the chain (or the final route handler). */
export type Next = () => Promise<Response> | Response;
@@ -99,12 +116,15 @@ export type PageMeta = SeoConfig;
export type PageComponent = (ctx: Context) => string | Promise<string>;
/** Create a fresh context for an incoming request. */
export function createContext(req: Request, url: URL): Context {
export function createContext<Params extends Record<string, string> = Record<string, string>>(
req: Request,
url: URL,
): Context<Params> {
const cookies = createCookieStore(req);
return {
req,
url,
params: {},
params: {} as Params,
locals: {},
lang: "",
t: (key) => key,
@@ -113,9 +133,24 @@ export function createContext(req: Request, url: URL): Context {
// cookies get `Secure` behind a TLS-terminating proxy (matches CSRF cookies).
session: createSessionStore(cookies, req, undefined, url.protocol === "https:"),
localStorage: createLocalStorageSnapshot(req),
pagination(options = {}) {
const defaultLimit = positivePaginationInteger(options.defaultLimit, 25);
const maxLimit = positivePaginationInteger(options.maxLimit, 100);
const requested = Number(url.searchParams.get("limit"));
const limit =
Number.isInteger(requested) && requested > 0
? Math.min(requested, maxLimit)
: Math.min(defaultLimit, maxLimit);
const cursor = url.searchParams.get("cursor")?.trim() || undefined;
return { limit, cursor };
},
};
}
function positivePaginationInteger(value: number | undefined, fallback: number): number {
return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
}
/** Apply headers accumulated on the context, such as Set-Cookie. */
export function withContextHeaders(ctx: Context, res: Response): Response {
const headers = new Headers(res.headers);
+29
View File
@@ -33,6 +33,10 @@ export interface EndpointDefinition<I, O> {
input?: SchemaLike<I> | OutputSchemaLike<I>;
output?: SchemaLike<O> | OutputSchemaLike<O>;
auth?: "optional" | "required";
/** Permission checked through an installed @wrnexus/authz middleware. */
permission?: string;
/** Resource supplied to bound authorization policies. */
resource?: (ctx: Context, input: I) => unknown | Promise<unknown>;
description?: string;
tags?: string[];
handler(input: I, ctx: Context): O | Promise<O>;
@@ -111,6 +115,31 @@ export function defineEndpoint(
: await ctx.req.json().catch(() => ({}));
input = schemaValue(definition.input, resolvedInput);
}
if (definition.permission) {
const authz = (
ctx as Context & {
authz?: {
decide(
permission: string,
resource?: unknown,
): Promise<{ allowed: boolean; reason?: string }>;
};
}
).authz;
if (!authz) {
throw new EndpointError(
500,
"AUTHZ_NOT_CONFIGURED",
"Authorization middleware is not configured.",
);
}
const resource = definition.resource ? await definition.resource(ctx, input) : undefined;
const decision = await authz.decide(definition.permission, resource);
if (!decision.allowed) {
throw new EndpointError(403, "FORBIDDEN", "Permission denied.");
}
ctx.resource = resource;
}
const rawOutput = await definition.handler(input, ctx);
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
return output instanceof Response ? output : json({ data: output });
+3
View File
@@ -9,6 +9,8 @@ export type {
PageComponent,
SeoConfig,
TFunction,
PaginationOptions,
PaginationInput,
} from "./context.ts";
export { createContext, withContextHeaders } from "./context.ts";
export {
@@ -127,6 +129,7 @@ export {
export type {
CookieOptions,
CookieStore,
TransactionCookie,
LocalStorageSnapshot,
SessionStore,
SessionBackend,
+49 -1
View File
@@ -17,6 +17,16 @@ export interface CookieStore {
set(name: string, value: string, options?: CookieOptions): void;
delete(name: string, options?: CookieOptions): void;
headers(): string[];
transaction<T extends Record<string, unknown> = Record<string, unknown>>(
name: string,
options?: CookieOptions,
): TransactionCookie<T>;
}
export interface TransactionCookie<T extends Record<string, unknown>> {
set(value: T): void;
consume(): T | undefined;
clear(): void;
}
export interface SessionStore {
@@ -259,7 +269,7 @@ export function createCookieStore(req: Request): CookieStore {
const incoming = parseCookieHeader(req.headers.get("cookie") ?? "");
const outgoing: string[] = [];
return {
const store: CookieStore = {
get(name) {
return incoming[name];
},
@@ -287,7 +297,45 @@ export function createCookieStore(req: Request): CookieStore {
headers() {
return [...outgoing];
},
transaction<T extends Record<string, unknown>>(name: string, options: CookieOptions = {}) {
const policy: CookieOptions = {
path: "/",
httpOnly: true,
sameSite: "Lax",
maxAge: 600,
secure: new URL(req.url).protocol === "https:",
...options,
};
return {
set(value: T) {
const json = JSON.stringify(value);
const encoded = btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
store.set(name, encoded, policy);
},
consume(): T | undefined {
const encoded = store.get(name);
store.delete(name, policy);
if (!encoded) return undefined;
try {
const padded = encoded
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "=");
return JSON.parse(decodeURIComponent(escape(atob(padded)))) as T;
} catch {
return undefined;
}
},
clear() {
store.delete(name, policy);
},
};
},
};
return store;
}
export function createSessionStore(
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { createContext } from "../src/index.ts";
test("context pagination applies defaults, bounds and cursor parsing", () => {
const request = new Request("https://example.test/items?limit=500&cursor=next-1");
const ctx = createContext(request, new URL(request.url));
expect(ctx.pagination({ defaultLimit: 25, maxLimit: 100 })).toEqual({
limit: 100,
cursor: "next-1",
});
});
test("context pagination rejects invalid limits", () => {
const request = new Request("https://example.test/items?limit=-2");
const ctx = createContext(request, new URL(request.url));
expect(ctx.pagination()).toEqual({ limit: 25, cursor: undefined });
});
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test";
import { createContext, withContextHeaders } from "../src/index.ts";
test("transaction cookies preserve policy when consumed and delete exactly once", () => {
const firstRequest = new Request("https://example.test/oauth");
const first = createContext(firstRequest, new URL(firstRequest.url));
first.cookies.transaction<{ state: string }>("oauth").set({ state: "välue" });
const issued = withContextHeaders(first, new Response()).headers.get("set-cookie")!;
expect(issued).toContain("HttpOnly");
expect(issued).toContain("SameSite=Lax");
expect(issued).toContain("Secure");
const pair = issued.split(";", 1)[0]!;
const callbackRequest = new Request("https://example.test/callback", {
headers: { cookie: pair },
});
const callback = createContext(callbackRequest, new URL(callbackRequest.url));
expect(callback.cookies.transaction<{ state: string }>("oauth").consume()).toEqual({
state: "välue",
});
const consumed = withContextHeaders(callback, new Response()).headers.get("set-cookie")!;
expect(consumed).toContain("Max-Age=0");
expect(consumed).toContain("Path=/");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.27",
"version": "0.8.28",
"type": "module",
"main": "src/index.ts",
"exports": {
+31
View File
@@ -980,6 +980,34 @@ export const REACTIVE_RUNTIME = String.raw`
renderers[index]();
}
}
/*
* Write loop locals onto a client-rendered item, mirroring the
* data-wrn-loop-locals attribute the server emits for an each block.
*
* Client data-for used to pass locals to hydration in memory only, so
* anything that resolves them by READING the DOM -- a component output
* binding calls decodeLoopLocals(componentRoot) -- found nothing and
* silently dropped the value. A plain DOM handler kept working, because it
* receives locals through the hydration closure, which is what made the
* failure look arbitrary. The DOM is the single source of truth for
* locals; both paths write it now.
*/
function writeLoopLocals(node, locals) {
if (!node || node.nodeType !== 1 || !locals) return;
try {
var json = JSON.stringify(locals);
if (json === undefined) return;
node.setAttribute(
"data-wrn-loop-locals",
window.btoa(unescape(encodeURIComponent(json))),
);
} catch (error) {
console.error("[wrnexus] failed to encode loop locals", error);
}
}
function decodeLoopLocals(node) {
if (!node) {
return {};
@@ -2216,6 +2244,8 @@ export const REACTIVE_RUNTIME = String.raw`
itemIndex;
}
writeLoopLocals(clone, locals);
hydrateItem(
clone,
locals,
@@ -2292,6 +2322,7 @@ export const REACTIVE_RUNTIME = String.raw`
}
var keyedClone = template.cloneNode(true);
writeLoopLocals(keyedClone, keyedLocals);
hydrateItem(keyedClone, keyedLocals);
record = {
node: keyedClone,
+92
View File
@@ -0,0 +1,92 @@
import { test, expect } from "bun:test";
import { Window } from "happy-dom";
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
import { restoreGlobalsAfterAll } from "./global-restore.ts";
restoreGlobalsAfterAll([
"window",
"document",
"location",
"NodeFilter",
"MutationObserver",
"CustomEvent",
]);
function mount(html: string): Window {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `<div id="app">${html}</div>`;
const g = globalThis as Record<string, unknown>;
g.window = win;
g.document = win.document;
g.location = win.location;
g.NodeFilter = (win as unknown as { NodeFilter: unknown }).NodeFilter;
g.MutationObserver = (win as unknown as { MutationObserver: unknown }).MutationObserver;
g.CustomEvent = (win as unknown as { CustomEvent: unknown }).CustomEvent;
(0, eval)(REACTIVE_RUNTIME);
return win;
}
// A client-rendered `data-for` passed its loop locals to hydration in memory but
// never wrote the `data-wrn-loop-locals` marker the SSR path writes. Anything
// that resolves locals by READING the DOM -- notably a component's
// `data-wrn-out-*` output binding, which calls decodeLoopLocals(componentRoot)
// -- therefore found nothing and silently dropped the value, with no console
// error. A plain DOM `@click` kept working, because it receives locals through
// the hydration closure instead, which is what made this look arbitrary.
test("a client-rendered for-loop item carries its loop locals in the DOM", () => {
const win = mount(
`<div data-scope="items: [{code: 'small'}, {code: 'large'}]">
<div data-for="item in items" data-component="Card" data-wrn-out-action="pick(item.code)"></div>
</div>`,
);
const mounts = win.document.querySelectorAll("[data-component='Card']");
expect(mounts.length).toBe(2);
const decoded = Array.from(mounts).map((node) => {
const raw = (node as unknown as Element).getAttribute("data-wrn-loop-locals");
expect(raw).toBeTruthy();
return JSON.parse(Buffer.from(String(raw), "base64").toString("utf8"));
});
// The marker must carry the real item, so an output binding naming `item`
// resolves it rather than silently dropping the call.
expect(decoded[0].item).toEqual({ code: "small" });
expect(decoded[1].item).toEqual({ code: "large" });
});
// The keyed path builds its clones separately, so it needs its own coverage --
// a fix applied to only one of the two loop paths leaves half the bug in place.
test("a keyed for-loop item carries its loop locals too", () => {
const win = mount(
`<div data-scope="rows: [{id: 1, code: 'a'}, {id: 2, code: 'b'}]">
<div data-for="row in rows key row.id" data-component="Card" data-wrn-out-action="pick(row.code)"></div>
</div>`,
);
const decoded = Array.from(win.document.querySelectorAll("[data-component='Card']"), (node) => {
const raw = (node as unknown as Element).getAttribute("data-wrn-loop-locals");
expect(raw).toBeTruthy();
return JSON.parse(Buffer.from(String(raw), "base64").toString("utf8"));
});
expect(decoded.map((d) => d.row.code)).toEqual(["a", "b"]);
});
// The encoder goes through UTF-8 before base64, as the server's does. btoa on a
// raw JS string throws on any character above U+00FF, which would take out the
// whole loop for an ordinary non-ASCII label.
test("loop locals survive non-ASCII values", () => {
const win = mount(
`<div data-scope="rows: [{label: 'Ünïcode — 日本語'}]">
<div data-for="row in rows" data-component="Card" data-wrn-out-action="pick(row.label)"></div>
</div>`,
);
const raw = win.document
.querySelector("[data-component='Card']")!
.getAttribute("data-wrn-loop-locals");
expect(raw).toBeTruthy();
const decoded = JSON.parse(Buffer.from(String(raw), "base64").toString("utf8"));
expect(decoded.row.label).toBe("Ünïcode — 日本語");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.8.16",
"version": "0.8.18",
"private": true,
"type": "module",
"main": "./src/index.ts",
+10 -1
View File
@@ -29,8 +29,17 @@ export interface ModelRef {
model: Model;
}
/** Parse annotated queries from one `.sql` file's contents. */
/**
* Parse annotated queries from one `.sql` file's contents.
*
* Line endings are normalised to LF first. The generator embeds each query's
* SQL as a string literal, so without this a CRLF checkout regenerated every
* query with CRLF where the committed file had LF -- a build dirtied the
* working tree, and that churn buried real changes in the same file. Line
* endings carry no meaning in SQL.
*/
export function parseQueries(content: string): QueryDef[] {
content = content.replace(/\r\n?/g, "\n");
const out: QueryDef[] = [];
const re = /--\s*name:\s*(\w+)\s*:(one|many|exec)\b[^\n]*\n([\s\S]*?)(?=--\s*name:|$)/gi;
let m: RegExpExecArray | null;
+28
View File
@@ -63,6 +63,34 @@ export function withTransaction<T>(db: Db, callback: (tx: Db) => Promise<T>): Pr
return db.tx(callback);
}
/** Execute a parameterized compare-and-set update and report whether this caller won. */
export async function compareAndSet(
db: Db,
options: {
table: string;
idColumn?: string;
id: string | number;
stateColumn?: string;
from: string;
to: string;
extra?: Record<string, unknown>;
},
): Promise<boolean> {
const table = identifier(options.table);
const idColumn = identifier(options.idColumn ?? "id");
const stateColumn = identifier(options.stateColumn ?? "status");
const entries = Object.entries(options.extra ?? {});
const assignments = [stateColumn, ...entries.map(([name]) => identifier(name))];
const dialect = db.driver.dialect;
const values = [options.to, ...entries.map(([, value]) => value), options.id, options.from];
const sql = `UPDATE ${table} SET ${assignments
.map((name, index) => `${name} = ${placeholder(dialect, index + 1)}`)
.join(
", ",
)} WHERE ${idColumn} = ${placeholder(dialect, assignments.length + 1)} AND ${stateColumn} = ${placeholder(dialect, assignments.length + 2)}`;
return (await db.exec(sql, values)).changes === 1;
}
/** Conservative default classifier for deadlock/serialization retry errors. */
export function isRetryableTransactionError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
+1
View File
@@ -54,6 +54,7 @@ export {
exists,
countRows,
withTransaction,
compareAndSet,
retryTransaction,
batch,
databaseHealth,
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
import { parseQueries } from "../src/generate.ts";
// The generator embeds each query's SQL as a string literal. It used to embed
// whatever line endings the checkout happened to have, so on a CRLF checkout
// every regenerated query differed from the committed one by `\n` -> `\r\n`.
// A build therefore dirtied the working tree, and the churn buried real
// changes in the same file. Line endings carry no meaning in SQL, so the
// parser normalises them and generated output stays stable across platforms.
test("query SQL is normalised to LF regardless of the checkout's line endings", () => {
const lf = "-- name: GetOne :one\nSELECT a,\n b\nFROM t\nWHERE id = :id;\n";
const crlf = lf.replace(/\n/g, "\r\n");
const fromLf = parseQueries(lf);
const fromCrlf = parseQueries(crlf);
expect(fromCrlf).toEqual(fromLf);
expect(fromCrlf[0]!.sql).not.toContain("\r");
// The SQL itself must still be intact, not merely stripped of carriage returns.
expect(fromCrlf[0]!.sql).toContain("SELECT a,");
expect(fromCrlf[0]!.sql).toContain("WHERE id = :id");
});
test("a lone CR does not survive into the embedded SQL either", () => {
const cr = "-- name: GetOne :one\rSELECT 1\r";
for (const q of parseQueries(cr)) expect(q.sql).not.toContain("\r");
});
+12
View File
@@ -5,6 +5,7 @@ import {
createRepository,
databaseHealth,
retryTransaction,
compareAndSet,
} from "../src/index.ts";
import type { Driver, Row } from "../src/index.ts";
@@ -28,6 +29,17 @@ function memoryDriver(): Driver {
}
describe("database helper kit", () => {
test("compareAndSet reports whether the guarded transition won", async () => {
const db = createDb(memoryDriver());
expect(
await compareAndSet(db, {
table: "messages",
id: 1,
from: "pending",
to: "sending",
}),
).toBe(true);
});
test("provides repository CRUD helpers and health checks", async () => {
const db = createDb(memoryDriver());
const repository = createRepository<{ id: number; name: string }>(db, {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.8.44",
"version": "0.8.45",
"type": "module",
"main": "src/index.ts",
"exports": {
+76 -1
View File
@@ -1580,6 +1580,55 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
});
}
const authorization = (
mod.authorization as
| Record<
string,
| string
| {
permission: string;
resource?: (ctx: Context) => unknown | Promise<unknown>;
exposeReason?: boolean;
mayDiscover?: boolean | ((ctx: Context) => boolean | Promise<boolean>);
}
>
| undefined
)?.[method];
if (authorization) {
if (!ctx.user) {
return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const rule =
typeof authorization === "string" ? { permission: authorization } : authorization;
const resource = rule.resource ? await rule.resource(ctx) : undefined;
if (rule.resource && resource == null) {
const mayDiscover =
typeof rule.mayDiscover === "function"
? await rule.mayDiscover(ctx)
: Boolean(rule.mayDiscover);
return Response.json(
{ ok: false, error: mayDiscover ? "Not Found" : "Forbidden" },
{ status: mayDiscover ? 404 : 403 },
);
}
const decision = ctx.authz ? await ctx.authz.decide(rule.permission, resource) : undefined;
const permissions = ctx.locals.permissions;
const allowed = decision
? decision.allowed
: typeof permissions === "function"
? await permissions(rule.permission, ctx)
: Array.isArray(permissions) && permissions.includes(rule.permission);
if (!allowed) {
return Response.json(
rule.exposeReason
? { ok: false, error: "Forbidden", reason: decision?.reason }
: { ok: false, error: "Forbidden" },
{ status: 403 },
);
}
ctx.resource = resource;
}
return (await handler(ctx)) as Response;
}
@@ -1740,6 +1789,33 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
const storeContainer = requestStoreContainer(ctx.req, matched.route.raw);
const mod = await loadModule(matched.route.file);
ctx.params = matched.params;
const pageSecurity = (mod.__wrnexusSecurity ?? {}) as Record<string, string>;
if (/^(?:required|true)$/i.test(pageSecurity.auth ?? "") && !ctx.user) {
if (ctx.req.method.toUpperCase() === "POST") {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const login = new URL("/login", ctx.url);
login.searchParams.set("returnTo", ctx.url.pathname + ctx.url.search);
return Response.redirect(login, 302);
}
if (pageSecurity.permission) {
const decision = ctx.authz ? await ctx.authz.decide(pageSecurity.permission) : undefined;
const permissions = ctx.locals.permissions;
const allowed = decision
? decision.allowed
: typeof permissions === "function"
? await permissions(pageSecurity.permission, ctx)
: Array.isArray(permissions) && permissions.includes(pageSecurity.permission);
if (!allowed) {
if (ctx.req.method.toUpperCase() === "POST") {
return Response.json({ error: "Permission denied" }, { status: 403 });
}
const forbidden = new URL(pageSecurity.forbidden ?? "/forbidden", ctx.url);
if (decision?.reason) forbidden.searchParams.set("reason", decision.reason);
return Response.redirect(forbidden, 303);
}
}
if (ctx.req.method.toUpperCase() === "POST") {
const actionResponse = await handlePageAction(ctx, mod);
if (actionResponse) return actionResponse;
@@ -1749,7 +1825,6 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
throw new Error(`Page ${matched.route.file} has no default export`);
}
ctx.params = matched.params;
const meta = (mod.meta ?? {}) as PageMeta;
const pageNavigation = (mod.__wrnexusNavigation ?? {}) as { preserve?: string };
const pageCache = (mod.__wrnexusCache ?? {}) as Record<string, string>;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"main": "src/index.ts",
+25 -4
View File
@@ -14,6 +14,8 @@ export interface QueueStore {
remove(id: string): Promise<void>;
due(now: number, limit: number): Promise<Job[]>;
list(name?: string): Promise<Job[]>;
findByIdempotencyKey?(name: string, key: string): Promise<Job | null>;
size?(): Promise<number>;
claim?(id: string, worker: string, leaseUntil: number): Promise<boolean>;
}
@@ -48,6 +50,15 @@ export function memoryQueueStore(): QueueStore {
.filter((job) => !name || job.name === name)
.map((job) => structuredClone(job));
},
async findByIdempotencyKey(name, key) {
const job = [...jobs.values()].find(
(candidate) => candidate.name === name && candidate.idempotencyKey === key,
);
return job ? structuredClone(job) : null;
},
async size() {
return jobs.size;
},
};
}
@@ -73,6 +84,10 @@ export interface DurableQueue {
list(name?: string): Promise<Job[]>;
failed(): Job[];
retry(id: string): Promise<boolean>;
addBatch<T>(
name: string,
entries: readonly { data: T; options?: AddOptions }[],
): Promise<Job<T>[]>;
}
function positiveInteger(value: number, label: string): number {
@@ -178,12 +193,12 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
}
if (add.idempotencyKey) {
const existing = (await store.list(name)).find(
(job) => job.idempotencyKey === add.idempotencyKey,
);
const existing = store.findByIdempotencyKey
? await store.findByIdempotencyKey(name, add.idempotencyKey)
: (await store.list(name)).find((job) => job.idempotencyKey === add.idempotencyKey);
if (existing) return existing as Job<T>;
}
if ((await store.list()).length >= capacity) {
if ((store.size ? await store.size() : (await store.list()).length) >= capacity) {
throw new Error(`WRN-QUEUE-CAPACITY: queue capacity of ${capacity} reached`);
}
@@ -204,6 +219,12 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
return structuredClone(job);
},
async addBatch<T>(name: string, entries: readonly { data: T; options?: AddOptions }[]) {
const output: Job<T>[] = [];
for (const entry of entries) output.push(await this.add(name, entry.data, entry.options));
return output;
},
process(name, handler) {
if (!name.trim()) throw new TypeError("queue worker name cannot be empty");
handlers.set(name, handler as JobHandler);
+3
View File
@@ -92,6 +92,9 @@ export function defineJob<I>(definition: JobDefinition<I>): JobDefinition<I> {
return definition;
}
export { defineWorker, runWorker } from "./worker.ts";
export type { WorkerDefinition } from "./worker.ts";
export interface WorkflowStep<I, O> {
name: string;
run(input: I): O | Promise<O>;
+25
View File
@@ -54,6 +54,18 @@ export function redisQueueStore(client: RedisQueueClient, prefix = "wrnexus:queu
.map((value) => JSON.parse(value) as Job)
.filter((job) => !name || job.name === name);
},
async findByIdempotencyKey(name, idempotencyKey) {
const ids = await client.smembers(jobs);
const values = await Promise.all(ids.map((id) => client.get(key(id))));
const found = values
.filter((value): value is string => value !== null)
.map((value) => JSON.parse(value) as Job)
.find((job) => job.name === name && job.idempotencyKey === idempotencyKey);
return found ?? null;
},
async size() {
return (await client.smembers(jobs)).length;
},
async claim(id, worker, leaseUntil) {
const ttl = Math.max(1, leaseUntil - Date.now());
return Boolean(await client.set(`${prefix}:lease:${id}`, worker, { NX: true, PX: ttl }));
@@ -98,6 +110,19 @@ export function postgresQueueStore(db: SqlQueueClient, table = "wrnexus_jobs"):
);
return result.rows.map((row) => row.payload);
},
async findByIdempotencyKey(name, key) {
const result = await db.query<{ payload: Job }>(
`SELECT payload FROM ${table} WHERE name=$1 AND payload->>'idempotencyKey'=$2 LIMIT 1`,
[name, key],
);
return result.rows[0]?.payload ?? null;
},
async size() {
const result = await db.query<{ total: number | string }>(
`SELECT COUNT(*) AS total FROM ${table}`,
);
return Number(result.rows[0]?.total ?? 0);
},
async claim(id, worker, leaseUntil) {
const result = await db.query(
`UPDATE ${table} SET lease_owner=$2,lease_until=$3 WHERE id=$1 AND (lease_until IS NULL OR lease_until < $4) RETURNING id`,
+36
View File
@@ -0,0 +1,36 @@
import type { JobHandler } from "./index.ts";
import type { DurableQueue } from "./durable.ts";
export interface WorkerDefinition<T> {
name: string;
handler: JobHandler<T>;
onStart?: () => void | Promise<void>;
onStop?: () => void | Promise<void>;
}
export function defineWorker<T>(definition: WorkerDefinition<T>): WorkerDefinition<T> {
if (!definition.name.trim()) throw new TypeError("worker name cannot be empty");
return Object.freeze({ ...definition });
}
/** Explicit process lifecycle for queue workers; safe to start and stop repeatedly. */
export function runWorker<T>(queue: DurableQueue, definition: WorkerDefinition<T>) {
let started = false;
return {
async start() {
if (started) return;
started = true;
queue.process(definition.name, definition.handler);
await definition.onStart?.();
},
async stop(options?: { force?: boolean }) {
if (!started) return;
started = false;
await queue.shutdown(options);
await definition.onStop?.();
},
get running() {
return started;
},
};
}
+19
View File
@@ -5,8 +5,27 @@ import {
cronToInterval,
defineWorkflow,
memoryQueueStore,
defineWorker,
runWorker,
} from "../src/index.ts";
test("durable queues batch enqueue and worker lifecycle are idempotent", async () => {
const queue = createDurableQueue();
const seen: number[] = [];
const worker = runWorker(
queue,
defineWorker<number>({ name: "number", handler: (job) => void seen.push(job.data) }),
);
await worker.start();
await worker.start();
const jobs = await queue.addBatch("number", [{ data: 1 }, { data: 2 }]);
expect(jobs).toHaveLength(2);
await queue.drain();
expect(seen).toEqual([1, 2]);
await worker.stop();
expect(worker.running).toBe(false);
});
test("processes a job", async () => {
const queue = createQueue();
const done: string[] = [];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.8.16",
"version": "0.8.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+16 -3
View File
@@ -330,6 +330,19 @@ export interface AppConfig {
profiles?: Record<string, Partial<Omit<AppConfig, "profiles">>>;
}
/** User-authored configuration; retained as an explicit name for API clarity. */
export type AppConfigInput = AppConfig;
/** Configuration after profile/layer resolution. Required input fields stay required. */
export type ResolvedAppConfig<T extends AppConfig = AppConfig> = Omit<
AppConfig,
"extends" | "profiles"
> &
Omit<T, "extends" | "profiles"> & {
readonly extends?: undefined;
readonly profiles?: undefined;
};
const CONFIG_NAMES = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
/**
@@ -474,7 +487,7 @@ export async function loadRawConfig(appRoot: string): Promise<AppConfig> {
}
/** Load `wrnexus.config.*`, applying the active profile's overrides. */
export async function loadAppConfig(appRoot: string, profile?: string): Promise<AppConfig> {
export async function loadAppConfig(appRoot: string, profile?: string): Promise<ResolvedAppConfig> {
const active = profile ?? resolveProfile();
// Configuration modules commonly read DATABASE_URL and other settings during
// module evaluation. Load the profile cascade before importing the module so
@@ -494,7 +507,7 @@ export async function loadAppConfig(appRoot: string, profile?: string): Promise<
`Invalid wrnexus.config: ${errors.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`,
);
}
return merged;
return merged as ResolvedAppConfig;
}
/**
@@ -573,7 +586,7 @@ export interface ConfigIssue {
message: string;
}
export function defineConfig(config: AppConfig): AppConfig {
export function defineConfig<const T extends AppConfig>(config: T): T {
return config;
}
+2
View File
@@ -9,6 +9,8 @@
export type {
AppConfig,
AppConfigInput,
ResolvedAppConfig,
BuildConfig,
ConfigIssue,
DevToolbarConfig,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/syntax",
"version": "0.8.11",
"version": "0.8.13",
"type": "module",
"main": "src/index.ts",
"exports": {
+18
View File
@@ -393,6 +393,24 @@ export function parse(source: string): PageAst {
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
}
switch (kw.value) {
case "auth": {
lx.next();
expect("eq");
const value = lx.next();
if (value.type !== "ident" || !["true", "false"].includes(value.value)) {
throw new ParseError(`Expected auth = true or false at offset ${value.pos}`);
}
security.auth = value.value === "true" ? "required" : "false";
break;
}
case "permission": {
lx.next();
expect("eq");
const value = expect("string").value.trim();
if (!value) throw new ParseError(`Page permission cannot be empty at offset ${kw.pos}`);
security.permission = value;
break;
}
case "layout": {
// layout = "public" — selects app/layouts/<name>.wrn for this page.
lx.next();
+121 -4
View File
@@ -48,6 +48,85 @@ export const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
* reimplementing it a second hand-rolled scanner is how apostrophes in
* prose used to swallow braces.
*/
/**
* Identifiers that can precede a `/` without ending an expression, so the `/`
* opens a regex rather than dividing.
*/
const REGEX_PRECEDING_KEYWORDS = new Set([
"return",
"typeof",
"instanceof",
"in",
"of",
"new",
"delete",
"void",
"do",
"else",
"yield",
"await",
"case",
]);
/**
* Decide whether the `/` at `i` opens a regex literal or is a division sign.
*
* Scans backwards for the last significant character. `a / b` divides; `(/a/)`,
* `= /a/` and `return /a/` do not. Erring towards division is the safe
* direction -- mistaking division for a regex would swallow everything to the
* next `/` and lose any braces in between.
*/
function opensRegex(src: string, i: number): boolean {
let j = i - 1;
while (j >= 0 && (src[j] === " " || src[j] === "\t" || src[j] === "\r" || src[j] === "\n")) j--;
if (j < 0) return true;
const prev = src[j]!;
if (/[A-Za-z0-9_$]/.test(prev)) {
// An identifier ends an expression, so `/` divides -- unless it is a
// keyword that cannot end one, like `return`.
let k = j;
while (k >= 0 && /[A-Za-z0-9_$]/.test(src[k]!)) k--;
return REGEX_PRECEDING_KEYWORDS.has(src.slice(k + 1, j + 1));
}
// `)` and `]` close an expression, `.` continues one, and a quote ends a
// literal; anything else leaves us in a position where a regex may start.
return (
prev !== ")" && prev !== "]" && prev !== "." && prev !== '"' && prev !== "'" && prev !== "`"
);
}
/**
* Scan a regex literal starting at `i`, returning the index just past its
* closing `/` and flags, or null when this is not in fact a regex.
*
* A regex literal cannot span a newline, so an unterminated one is treated as
* "not a regex" rather than swallowing the rest of the file. That is what keeps
* a bare URL in view text (`https://example.com/a//b`) intact.
*/
function skipRegex(src: string, i: number): number | null {
let j = i + 1;
let inClass = false;
while (j < src.length) {
const c = src[j]!;
if (c === "\n") return null;
if (c === "\\") {
j += 2;
continue;
}
if (inClass) {
if (c === "]") inClass = false;
} else if (c === "[") {
inClass = true;
} else if (c === "/") {
j++;
while (j < src.length && /[a-z]/.test(src[j]!)) j++;
return j;
}
j++;
}
return null;
}
export function skipLiteralOrComment(src: string, i: number, atLineStart: boolean): number | null {
const c = src[i];
if (c === "/" && src[i + 1] === "*") {
@@ -70,6 +149,13 @@ export function skipLiteralOrComment(src: string, i: number, atLineStart: boolea
}
return src.length;
}
// A regex literal is neither a string nor a brace pair, but it can contain
// both. Without this, a quote inside one opened a phantom string that
// swallowed every brace to the next quote, and a lone `{`/`}` miscounted
// block depth.
if (c === "/" && opensRegex(src, i)) {
return skipRegex(src, i);
}
return null;
}
@@ -77,8 +163,13 @@ export class Lexer {
pos = 0;
constructor(public readonly src: string) {}
/** Skip whitespace and `// line comments`. */
private skipTrivia(): void {
/**
* Skip whitespace and `// line comments`, but NOT block comments.
*
* Kept separate from `skipTrivia` so `startsWithBlockComment` can still see a
* block comment that `skipTrivia` would otherwise consume.
*/
private skipWhitespaceAndLineComments(): void {
const { src } = this;
while (this.pos < src.length) {
const c = src[this.pos]!;
@@ -94,9 +185,35 @@ export class Lexer {
}
}
/** True when the next non-trivia characters open a block comment. */
/**
* Skip whitespace, line comments and block comments.
*
* Block comments used to be skipped only inside a braced body, so one written
* between two members failed with a bare "Unexpected character '/'" -- the
* same comment parsed or did not depending on where it sat.
*/
private skipTrivia(): void {
const { src } = this;
while (this.pos < src.length) {
this.skipWhitespaceAndLineComments();
if (src[this.pos] === "/" && src[this.pos + 1] === "*") {
const close = src.indexOf("*/", this.pos + 2);
this.pos = close === -1 ? src.length : close + 2;
continue;
}
break;
}
}
/**
* True when the next non-trivia characters open a block comment.
*
* Deliberately skips only whitespace and line comments: `props {}` refuses
* block comments with an explained error, and that check must run before
* `skipTrivia` would swallow the comment and drop a declaration silently.
*/
startsWithBlockComment(): boolean {
this.skipTrivia();
this.skipWhitespaceAndLineComments();
return this.src[this.pos] === "/" && this.src[this.pos + 1] === "*";
}
+11
View File
@@ -0,0 +1,11 @@
import { expect, test } from "bun:test";
import { parse } from "../src/index.ts";
test("pages accept concise authentication and permission guards", () => {
const ast = parse(`page Dashboard {
auth = true
permission = "dashboard:read"
view { <h1>Dashboard</h1> }
}`);
expect(ast.security).toEqual({ auth: "required", permission: "dashboard:read" });
});
+102
View File
@@ -235,3 +235,105 @@ test("comments may contain apostrophes without unbalancing a block", () => {
// The URL in view text must survive: `//` is only a comment at line start.
expect(JSON.stringify(ast.view)).toContain("https://example.com/a//b");
});
// A regex literal is not a string and not a pair of braces. The brace scanner
// knew about quotes and comments but had no case for regexes, so a quote inside
// one opened a phantom string that swallowed every brace until the next quote,
// and a lone `{` or `}` inside one miscounted depth. Both failed the whole
// component -- with a green build in the reported case, because the damage
// landed in generated output rather than at parse time.
test("regex literals do not unbalance a block", () => {
const mk = (body: string) =>
`page P {
load server {
${body}
return { x };
}
view { <div>{x}</div> }
}
`;
// A quote inside a regex used to open a string that ran to the next quote.
expect(parse(mk(` const x = /it's/.test("its");`)).name).toBe("P");
expect(parse(mk(` const x = /"/.test("q");`)).name).toBe("P");
// A brace inside a regex used to be counted as block depth.
expect(parse(mk(` const x = /\\{/.test("{");`)).name).toBe("P");
expect(parse(mk(` const x = /}/.test("}");`)).name).toBe("P");
// A brace quantifier is balanced, but must not be counted either.
expect(parse(mk(` const x = /^a{2,3}$/.test("aa");`)).name).toBe("P");
// A `/` inside a character class does not close the regex.
expect(parse(mk(` const x = /[/'"{]/.test("/");`)).name).toBe("P");
// The case that already worked must keep working.
expect(parse(mk(` const x = "a-b".replace(/-/g, " ");`)).name).toBe("P");
});
// Division must not be mistaken for a regex, or the scanner would swallow code
// from the `/` to the next one and lose any braces in between.
test("division is not treated as a regex literal", () => {
const source = `page P {
load server {
const half = 10 / 2;
const ratio = (a + b) / 2;
const each = items[0] / total;
if (half > 1) {
return { half };
}
return { half: 0 };
}
view { <div>{half}</div> }
}
`;
expect(parse(source).name).toBe("P");
});
// `skipTrivia` skipped `// line comments` but not `/* block comments */`, so a
// block comment between page or component members failed with a bare
// "Unexpected character '/'". Block comments inside a braced body already
// worked, which made the failure look arbitrary: the same comment moved a few
// lines parsed or did not depending on whether it sat inside a block.
test("block comments are allowed between members", () => {
const page = `page P {
/* Explains the state below. */
state x = 1
/* Explains the view below. */
view { <div>{x}</div> }
}
`;
expect(parse(page).name).toBe("P");
const component = `component C {
/* A note about this component. */
state y = 2
view { <p>{y}</p> }
}
`;
expect(parse(component).name).toBe("C");
// A block comment before the opening brace, and a multi-line one.
const spaced = `/* Leading note. */
page Q {
/*
* A multi-line note about the view.
*/
view { <div>ok</div> }
}
`;
expect(parse(spaced).name).toBe("Q");
});
// Block comments inside props {} stay a deliberate, explained error -- the
// props parser cannot represent them, and silently skipping one would drop a
// declaration the author believed was there.
test("a block comment inside props is still refused with its own message", () => {
const source = `component C {
props {
/* not allowed here */
name: string
}
view { <p>{name}</p> }
}
`;
expect(() => parse(source)).toThrow("Block comments are not allowed inside props");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.8.9",
"version": "0.8.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+47 -1
View File
@@ -3,6 +3,9 @@ import { createContext, type Context, type ProblemDetails } from "@wrnexus/core"
export interface TestRequestOptions extends Omit<RequestInit, "body"> {
body?: BodyInit | Record<string, unknown> | URLSearchParams | FormData | null;
baseUrl?: string;
params?: Record<string, string>;
user?: unknown;
locals?: Record<string, unknown>;
}
/** Build a web-standard Request with convenient JSON/FormData handling. */
@@ -31,7 +34,50 @@ export function testRequest(path = "/", options: TestRequestOptions = {}): Reque
/** Create a complete Context suitable for middleware and route unit tests. */
export function testContext(path = "/", options: TestRequestOptions = {}): Context {
const request = testRequest(path, options);
return createContext(request, new URL(request.url));
const ctx = createContext(request, new URL(request.url));
ctx.params = { ...options.params };
ctx.user = options.user;
ctx.locals = { ...options.locals };
return ctx;
}
/** Preferred descriptive alias for testContext(). */
export const createTestContext = testContext;
/** Fluent, Bun-compatible fetch mock (including Bun.fetch.preconnect). */
export type FetchMock = typeof fetch & {
readonly calls: ReadonlyArray<{ input: string | URL | Request; init?: RequestInit }>;
respondOnce(response: Response | (() => Response | Promise<Response>)): FetchMock;
reset(): void;
};
export function createFetchMock(): FetchMock {
const responses: Array<Response | (() => Response | Promise<Response>)> = [];
const calls: Array<{ input: string | URL | Request; init?: RequestInit }> = [];
const implementation = async (input: string | URL | Request, init?: RequestInit) => {
calls.push({ input, init });
const next = responses.shift();
if (!next) throw new Error("WRN-TEST-FETCH: no response was queued for this request");
return typeof next === "function" ? next() : next.clone();
};
const mock = implementation as FetchMock;
Object.defineProperties(mock, {
calls: { get: () => calls },
respondOnce: {
value(response: Response | (() => Response | Promise<Response>)) {
responses.push(response);
return mock;
},
},
reset: {
value() {
responses.length = 0;
calls.length = 0;
},
},
preconnect: { value: () => undefined },
});
return mock;
}
export interface JsonResponse<T> {
+12 -1
View File
@@ -181,15 +181,26 @@ export async function createHarness(
};
}
/** Preferred full-stack name; retained alongside createHarness for compatibility. */
export const createTestApp = createHarness;
export {
testRequest,
testContext,
createTestContext,
readJsonResponse,
expectProblem,
deferred,
waitFor,
MemoryCookieJar,
createFetchMock,
} from "./advanced.ts";
export type {
TestRequestOptions,
JsonResponse,
Deferred,
WaitForOptions,
FetchMock,
} from "./advanced.ts";
export type { TestRequestOptions, JsonResponse, Deferred, WaitForOptions } from "./advanced.ts";
export { withDatabaseRollback, createFactory, captureBrowserArtifacts } from "./platform.ts";
export type { TransactionalDatabase, BrowserArtifactPage } from "./platform.ts";
+28 -1
View File
@@ -1,4 +1,12 @@
import { test, expect, renderComponent, mountHtml, callRoute } from "../src/index.ts";
import {
test,
expect,
renderComponent,
mountHtml,
callRoute,
createFetchMock,
createTestContext,
} from "../src/index.ts";
const COUNTER = `component Counter {
props {
@@ -30,3 +38,22 @@ test("callRoute invokes an API handler with a Context", async () => {
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, path: "/api/ping", method: "POST" });
});
test("createTestContext installs route params, locals and a user without casts", () => {
const user = { id: "u1" };
const ctx = createTestContext("/items/42", {
params: { id: "42" },
user,
locals: { trace: "test" },
});
expect(ctx.params.id).toBe("42");
expect(ctx.user).toBe(user);
expect(ctx.locals.trace).toBe("test");
});
test("createFetchMock is fluent and compatible with Bun fetch", async () => {
const fetchMock: typeof fetch = createFetchMock().respondOnce(Response.json({ ok: true }));
const response = await fetchMock("https://example.test/token");
expect(await response.json()).toEqual({ ok: true });
expect(typeof fetchMock.preconnect).toBe("function");
});
+9 -1
View File
@@ -183,7 +183,15 @@ const runtimeBudgets = {
*/
// Raised to 51_400: the callApi transport (query building, CSRF header,
// JSON body, success/failure contract) for compiled api blocks bought ~1,025 bytes.
"reactive-runtime.ts": 51_400,
//
// Raised to 51_600 on 2026-08-22: writing `data-wrn-loop-locals` onto
// client-rendered for-loop items bought 263 bytes. Without it a component's
// output binding inside a loop resolved no locals and silently dropped every
// call, while a plain DOM handler in the same position worked -- so the cost
// buys a correctness fix, not a feature. The encoder was trimmed to the
// btoa/encodeURIComponent idiom first, which recovered 65 of those bytes;
// what remains is the smallest form that still handles non-ASCII.
"reactive-runtime.ts": 51_600,
"component-controllers.ts": 24_100,
"nav-runtime.ts": 12_000,
"realtime-runtime.ts": 8_000,