feat: centralize application framework primitives
Quality / quality (ubuntu-latest) (push) Failing after 14m38s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-22 23:07:46 +05:30
parent 96e082b943
commit a3ddd39b7b
73 changed files with 1429 additions and 84 deletions
+11
View File
@@ -2,6 +2,17 @@
## Unreleased ## 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` - 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 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. `"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g.
+11 -10
View File
@@ -235,7 +235,7 @@
}, },
"packages/auth": { "packages/auth": {
"name": "@wrnexus/auth", "name": "@wrnexus/auth",
"version": "0.8.14", "version": "0.8.15",
"dependencies": { "dependencies": {
"@wrnexus/authz": "workspace:*", "@wrnexus/authz": "workspace:*",
"@wrnexus/captcha": "workspace:*", "@wrnexus/captcha": "workspace:*",
@@ -257,10 +257,11 @@
}, },
"packages/authz": { "packages/authz": {
"name": "@wrnexus/authz", "name": "@wrnexus/authz",
"version": "0.8.9", "version": "0.8.10",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
"@wrnexus/db": "workspace:*", "@wrnexus/db": "workspace:*",
"@wrnexus/plugin": "workspace:*",
}, },
}, },
"packages/benchmark": { "packages/benchmark": {
@@ -291,7 +292,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.8.48", "version": "0.8.49",
"bin": { "bin": {
"wrnexus": "src/index.ts", "wrnexus": "src/index.ts",
}, },
@@ -337,7 +338,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.8.11", "version": "0.8.12",
}, },
"packages/csr": { "packages/csr": {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
@@ -348,7 +349,7 @@
}, },
"packages/db": { "packages/db": {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.8.17", "version": "0.8.18",
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@@ -356,7 +357,7 @@
}, },
"packages/dev-server": { "packages/dev-server": {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.8.44", "version": "0.8.45",
"dependencies": { "dependencies": {
"@wrnexus/authz": "workspace:*", "@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*", "@wrnexus/cache": "workspace:*",
@@ -545,7 +546,7 @@
}, },
"packages/queue": { "packages/queue": {
"name": "@wrnexus/queue", "name": "@wrnexus/queue",
"version": "0.8.8", "version": "0.8.9",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
"@wrnexus/rpc": "workspace:*", "@wrnexus/rpc": "workspace:*",
@@ -632,7 +633,7 @@
}, },
"packages/styles": { "packages/styles": {
"name": "@wrnexus/styles", "name": "@wrnexus/styles",
"version": "0.8.16", "version": "0.8.17",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*", "@wrnexus/plugin": "workspace:*",
@@ -641,11 +642,11 @@
}, },
"packages/syntax": { "packages/syntax": {
"name": "@wrnexus/syntax", "name": "@wrnexus/syntax",
"version": "0.8.12", "version": "0.8.13",
}, },
"packages/test": { "packages/test": {
"name": "@wrnexus/test", "name": "@wrnexus/test",
"version": "0.8.9", "version": "0.8.10",
}, },
"packages/tracking": { "packages/tracking": {
"name": "@wrnexus/tracking", "name": "@wrnexus/tracking",
+39 -3
View File
@@ -91,6 +91,7 @@
"AuthPluginOptions", "AuthPluginOptions",
"AuthPublicUser", "AuthPublicUser",
"AuthRandom", "AuthRandom",
"AuthRequiredError",
"AuthResult", "AuthResult",
"AuthRiskDecision", "AuthRiskDecision",
"AuthRiskLevel", "AuthRiskLevel",
@@ -210,6 +211,7 @@
"signUpSchema", "signUpSchema",
"totpUri", "totpUri",
"tryGetDefaultAuthEngine", "tryGetDefaultAuthEngine",
"validateProductionAuthConfig",
"verificationRequestSchema", "verificationRequestSchema",
"verificationTokenSchema", "verificationTokenSchema",
"verifyTotp" "verifyTotp"
@@ -229,6 +231,7 @@
], ],
"./middleware": [ "./middleware": [
"AUTH_SESSION_KEY", "AUTH_SESSION_KEY",
"AuthRequiredError",
"AuthSessionOptions", "AuthSessionOptions",
"RequireAuthOptions", "RequireAuthOptions",
"authSession", "authSession",
@@ -237,7 +240,8 @@
"getAuthSession", "getAuthSession",
"getAuthUser", "getAuthUser",
"isAuthenticatedContext", "isAuthenticatedContext",
"requireAuth" "requireAuth",
"requireAuthUser"
], ],
"./passkeys": [ "./passkeys": [
"MemoryPasskeyChallengeStore", "MemoryPasskeyChallengeStore",
@@ -252,11 +256,13 @@
"./plugin": [ "./plugin": [
"AuthAuditIssue", "AuthAuditIssue",
"AuthConfig", "AuthConfig",
"AuthOAuthProviderConfig",
"AuthPluginOptions", "AuthPluginOptions",
"AuthRoutesConfig", "AuthRoutesConfig",
"authComponentsDir", "authComponentsDir",
"authPlugin", "authPlugin",
"default" "default",
"validateProductionAuthConfig"
], ],
"./protector": [ "./protector": [
"createAuthSecretProtector" "createAuthSecretProtector"
@@ -460,10 +466,14 @@
"AUTHZ_LOCALS_KEY", "AUTHZ_LOCALS_KEY",
"AttributeMeta", "AttributeMeta",
"AuthorizationDecision", "AuthorizationDecision",
"AuthorizationResponses",
"AuthorizeDecisionOptions", "AuthorizeDecisionOptions",
"AuthorizedHandlerOptions",
"AuthzAuditEvent", "AuthzAuditEvent",
"AuthzAuditSink", "AuthzAuditSink",
"AuthzCatalog", "AuthzCatalog",
"AuthzConfig",
"AuthzIdentityEvent",
"AuthzModule", "AuthzModule",
"AuthzResolver", "AuthzResolver",
"AuthzResolverOptions", "AuthzResolverOptions",
@@ -476,10 +486,12 @@
"GrantEffect", "GrantEffect",
"GuardOptions", "GuardOptions",
"MemoryAuditSink", "MemoryAuditSink",
"OwnedResourceDefinition",
"PermissionMeta", "PermissionMeta",
"PermissionStore", "PermissionStore",
"Policy", "Policy",
"Rbac", "Rbac",
"RequestAuthorization",
"Subject", "Subject",
"SubjectAssignments", "SubjectAssignments",
"all", "all",
@@ -487,17 +499,21 @@
"allow", "allow",
"any", "any",
"anyDecision", "anyDecision",
"assignDefaultAuthzRoles",
"attr", "attr",
"authorize", "authorize",
"authorizeDecision", "authorizeDecision",
"authzMiddleware", "authzMiddleware",
"authzPlugin",
"cachedPermissionStore", "cachedPermissionStore",
"can", "can",
"consoleAuditSink", "consoleAuditSink",
"createAuthzResolver", "createAuthzResolver",
"decideFor", "decideFor",
"decision", "decision",
"defineAuthorizedHandler",
"defineAuthz", "defineAuthz",
"defineOwnedResource",
"defineRbac", "defineRbac",
"deniedBy", "deniedBy",
"deny", "deny",
@@ -507,6 +523,7 @@
"filterCan", "filterCan",
"generatePermissionTypes", "generatePermissionTypes",
"getAuthzCatalog", "getAuthzCatalog",
"getRequestAuthorization",
"guardPermission", "guardPermission",
"hasAuthzCatalog", "hasAuthzCatalog",
"hasRole", "hasRole",
@@ -519,12 +536,18 @@
"requireRole", "requireRole",
"safeRecord", "safeRecord",
"scopeKey", "scopeKey",
"setAuthzCatalog" "setAuthzCatalog",
"setDefaultAuthzRoles"
], ],
"./db": [ "./db": [
"authzMigrationSql", "authzMigrationSql",
"dbPermissionStore", "dbPermissionStore",
"ensureAuthzTables" "ensureAuthzTables"
],
"./plugin": [
"AuthzConfig",
"authzPlugin",
"default"
] ]
}, },
"@wrnexus/benchmark": { "@wrnexus/benchmark": {
@@ -1137,6 +1160,8 @@
"POSTGRES_TENANT_DIRECTORY_SCHEMA", "POSTGRES_TENANT_DIRECTORY_SCHEMA",
"PageComponent", "PageComponent",
"PageMeta", "PageMeta",
"PaginationInput",
"PaginationOptions",
"PerformanceBudgets", "PerformanceBudgets",
"PerformanceMeasurement", "PerformanceMeasurement",
"PermissionsPolicyConfig", "PermissionsPolicyConfig",
@@ -1198,6 +1223,7 @@
"TenantResource", "TenantResource",
"TenantSqlClient", "TenantSqlClient",
"Tracer", "Tracer",
"TransactionCookie",
"TrustedTypesConfig", "TrustedTypesConfig",
"UploadError", "UploadError",
"UploadInspectionResult", "UploadInspectionResult",
@@ -1380,6 +1406,7 @@
"applyMigrations", "applyMigrations",
"batch", "batch",
"closeDatabases", "closeDatabases",
"compareAndSet",
"countRows", "countRows",
"createDb", "createDb",
"createRepository", "createRepository",
@@ -2260,6 +2287,7 @@
"SqlQueueClient", "SqlQueueClient",
"SubjectJob", "SubjectJob",
"SubjectQueue", "SubjectQueue",
"WorkerDefinition",
"WorkflowDefinition", "WorkflowDefinition",
"WorkflowEngine", "WorkflowEngine",
"WorkflowRunContext", "WorkflowRunContext",
@@ -2275,6 +2303,7 @@
"cronToInterval", "cronToInterval",
"defineDurableWorkflow", "defineDurableWorkflow",
"defineJob", "defineJob",
"defineWorker",
"defineWorkflow", "defineWorkflow",
"memoryQueueStore", "memoryQueueStore",
"memoryWorkflowStore", "memoryWorkflowStore",
@@ -2283,6 +2312,7 @@
"redisQueueStore", "redisQueueStore",
"renderQueueDashboard", "renderQueueDashboard",
"runQueueDaemon", "runQueueDaemon",
"runWorker",
"subjectQueue" "subjectQueue"
] ]
}, },
@@ -2639,6 +2669,7 @@
".": [ ".": [
"ACCENT_COOKIE", "ACCENT_COOKIE",
"AppConfig", "AppConfig",
"AppConfigInput",
"BrowserCookieApi", "BrowserCookieApi",
"BrowserCookieOptions", "BrowserCookieOptions",
"BrowserCookiePreference", "BrowserCookiePreference",
@@ -2663,6 +2694,7 @@
"ObservabilityConfig", "ObservabilityConfig",
"PerformanceConfig", "PerformanceConfig",
"PwaConfig", "PwaConfig",
"ResolvedAppConfig",
"ResolvedConfigLayers", "ResolvedConfigLayers",
"ResolvedTheme", "ResolvedTheme",
"StyleProcessContext", "StyleProcessContext",
@@ -2877,6 +2909,7 @@
".": [ ".": [
"BrowserArtifactPage", "BrowserArtifactPage",
"Deferred", "Deferred",
"FetchMock",
"Harness", "Harness",
"HarnessOptions", "HarnessOptions",
"JsonResponse", "JsonResponse",
@@ -2892,7 +2925,10 @@
"captureBrowserArtifacts", "captureBrowserArtifacts",
"createContext", "createContext",
"createFactory", "createFactory",
"createFetchMock",
"createHarness", "createHarness",
"createTestApp",
"createTestContext",
"deferred", "deferred",
"describe", "describe",
"expect", "expect",
+20 -1
View File
@@ -1,6 +1,6 @@
"use strict"; "use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly. // Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: 0ce7440feda082e0baf1ad3a8f2a5c309f308b755bd074fa8cab6de16efb675e // WRN editor compiler source hash: 1e7593d6b6bd1c9b8439fc34a30f9db9f7ae64f1788365a74115170aa34cd185
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3 // Generated with TypeScript: 6.0.3
const __nodeRequire = require; const __nodeRequire = require;
@@ -6557,6 +6557,25 @@ function parse(source) {
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`); throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
} }
switch (kw.value) { 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": { case "layout": {
// layout = "public" — selects app/layouts/<name>.wrn for this page. // layout = "public" — selects app/layouts/<name>.wrn for this page.
lx.next(); lx.next();
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: e2d214e98eb953756974d4b731530ed432c2f9dca6f0aaee975453b2eec84d75 // WRN editor extension source hash: 7fd27405852b02ce3f64e8b7cc08e84029aca9f5505217caafe56be8d3ca4931
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict"; "use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
+20 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
// WRN editor language server source hash: cea070c2eebb5339c7b48f4200d34e51f2adb16eba4d6d3f92e5040081c5b079 // WRN editor language server source hash: 3c6060ff9db332cf1dc01a467d795fb2d002300400072a4b87437c48d61ab7f9
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs // @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create; (function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -171611,6 +171611,25 @@ function parse(source) {
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`); throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
} }
switch (kw.value) { 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": { case "layout": {
lx.next(); lx.next();
expect("eq"); expect("eq");
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/auth", "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.", "description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
+23 -1
View File
@@ -266,7 +266,29 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (options.secret.length < MIN_SECRET_LENGTH) { if (options.secret.length < MIN_SECRET_LENGTH) {
throw new TypeError(`auth secret must be at least ${MIN_SECRET_LENGTH} characters`); 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 now = () => {
const value = options.clock?.now() ?? Date.now(); const value = options.clock?.now() ?? Date.now();
if (!Number.isFinite(value)) throw new Error("WRN-AUTH-CLOCK: clock returned an invalid time"); 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"; } from "../middleware.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts"; import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
import type { AuthSessionVerificationHandler } from "../types.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 { function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value); return typeof value === "string" ? value : value == null ? "" : String(value);
@@ -50,6 +52,8 @@ export interface AuthHttpOptions {
schemas?: AuthSchemaOverrides | AuthSchemaSet; schemas?: AuthSchemaOverrides | AuthSchemaSet;
passkey?: AuthPasskeyHttpOptions; passkey?: AuthPasskeyHttpOptions;
onSessionVerification?: AuthSessionVerificationHandler; onSessionVerification?: AuthSessionVerificationHandler;
oauth?: Record<string, OAuthProvider>;
oauthMfaPath?: string;
} }
export function createAuthHttpHandlers(options: AuthHttpOptions) { export function createAuthHttpHandlers(options: AuthHttpOptions) {
@@ -59,6 +63,17 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const onSignedOut = engine.onSignedOut; const onSignedOut = engine.onSignedOut;
const onSuccessfulSignUp = engine.onSuccessfulSignUp; 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 { function signupRedirect(ctx: Context, value: string | undefined, fallback: string): Response {
const path = safeAuthReturnTo(value, ctx.url.origin) ?? fallback; const path = safeAuthReturnTo(value, ctx.url.origin) ?? fallback;
return Response.redirect(new URL(path, ctx.url), 303); return Response.redirect(new URL(path, ctx.url), 303);
@@ -80,6 +95,71 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
} }
return { 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> { async verifySession(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx); const user = getAuthUser(ctx);
if (options.onSessionVerification) { if (options.onSessionVerification) {
@@ -110,6 +190,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
}); });
if (!result.ok || !result.user) return json(result, 400); if (!result.ok || !result.user) return json(result, 400);
await assignDefaultAuthzRoles(result.user.id, "signup");
const action = await onSuccessfulSignUp?.(ctx, result.user); const action = await onSuccessfulSignUp?.(ctx, result.user);
if (action instanceof Response) return action; if (action instanceof Response) return action;
if (action?.autoSignIn) { if (action?.autoSignIn) {
@@ -230,6 +312,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
password: text(input.password) || undefined, password: text(input.password) || undefined,
displayName: text(input.displayName) || undefined, displayName: text(input.displayName) || undefined,
}); });
if (result.ok && result.user) await assignDefaultAuthzRoles(result.user.id, "invitation");
return json(result, result.ok ? 200 : 400); return json(result, result.ok ? 200 : 400);
}, },
@@ -403,12 +486,18 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const validation = await parseBody(schemas.mfaComplete, ctx.req); const validation = await parseBody(schemas.mfaComplete, ctx.req);
if (!validation.ok) return validation.response; if (!validation.ok) return validation.response;
const input = validation.value; 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 methodValue = text(input.method);
const method = ["totp", "recovery-code", "email-otp", "sms-otp"].includes(methodValue) const method = ["totp", "recovery-code", "email-otp", "sms-otp"].includes(methodValue)
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp") ? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
: "totp"; : "totp";
const result = await engine.completeMfa({ const result = await engine.completeMfa({
mfaToken: text(input.mfaToken), mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
method, method,
code: text(input.code), code: text(input.code),
challengeId: text(input.challengeId) || undefined, 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); if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user); establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) { 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); return json(result);
}, },
+3
View File
@@ -9,6 +9,8 @@ export {
clearAuthSession, clearAuthSession,
getAuthUser, getAuthUser,
getAuthSession, getAuthSession,
requireAuthUser,
AuthRequiredError,
isAuthenticatedContext, isAuthenticatedContext,
AUTH_SESSION_KEY, AUTH_SESSION_KEY,
} from "./middleware.ts"; } from "./middleware.ts";
@@ -20,6 +22,7 @@ export {
export { export {
authPlugin, authPlugin,
authComponentsDir, authComponentsDir,
validateProductionAuthConfig,
type AuthConfig, type AuthConfig,
type AuthRoutesConfig, type AuthRoutesConfig,
type AuthPluginOptions, 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 { export function getAuthSession(ctx: Context): AuthSession | null {
return (ctx.locals.authSession as AuthSession | undefined) ?? 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 { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin"; 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 { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts"; import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSessionVerificationHandler } from "./types.ts"; import type { AuthSessionVerificationHandler } from "./types.ts";
@@ -32,8 +39,11 @@ export interface AuthRoutesConfig {
sessions?: boolean; sessions?: boolean;
impersonation?: boolean; impersonation?: boolean;
passkeys?: boolean; passkeys?: boolean;
oauth?: boolean;
} }
export type AuthOAuthProviderConfig = OAuthProvider | ProviderCredentials;
export interface AuthConfig { export interface AuthConfig {
enabled?: boolean; enabled?: boolean;
engine?: AuthEngine; engine?: AuthEngine;
@@ -48,12 +58,36 @@ export interface AuthConfig {
baseUrl?: string; baseUrl?: string;
csrf?: boolean; csrf?: boolean;
passkey?: AuthPasskeyHttpOptions; 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. */ /** Shared SSO cookie whose value is an AuthEngine session id. */
sessionCookieName?: string; sessionCookieName?: string;
/** Customize the package forward-auth verification response. */ /** Customize the package forward-auth verification response. */
onSessionVerification?: AuthSessionVerificationHandler; 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. */ /** Explicit plugin options remain supported for compatibility. Prefer config.auth. */
export interface AuthPluginOptions { export interface AuthPluginOptions {
componentDir?: string; componentDir?: string;
@@ -88,6 +122,8 @@ interface ResolvedAuthConfig {
passkey?: AuthPasskeyHttpOptions; passkey?: AuthPasskeyHttpOptions;
sessionCookieName?: string; sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler; onSessionVerification?: AuthSessionVerificationHandler;
oauth: Record<string, OAuthProvider>;
oauthMfaPath?: string;
} }
const moduleRoot = dirname(fileURLToPath(import.meta.url)); const moduleRoot = dirname(fileURLToPath(import.meta.url));
@@ -121,6 +157,24 @@ function resolveConfig(
const enabled = raw.enabled !== false; const enabled = raw.enabled !== false;
const hasEngine = Boolean(raw.engine); const hasEngine = Boolean(raw.engine);
const hasDefaultDb = Boolean(config.db); 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 { return {
enabled, enabled,
@@ -148,6 +202,8 @@ function resolveConfig(
passkey: raw.passkey, passkey: raw.passkey,
sessionCookieName: raw.sessionCookieName, sessionCookieName: raw.sessionCookieName,
onSessionVerification: raw.onSessionVerification, onSessionVerification: raw.onSessionVerification,
oauth,
oauthMfaPath: raw.oauthMfaPath,
}; };
} }
@@ -176,6 +232,7 @@ function fallbackConfig(options: AuthPluginOptions): ResolvedAuthConfig {
schemas: resolveAuthSchemas(), schemas: resolveAuthSchemas(),
csrf: true, csrf: true,
oauth: {},
}; };
} }
@@ -353,6 +410,16 @@ export function authPlugin(options: AuthPluginOptions = {}) {
}, },
configure(config, context) { 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); const value = resolveConfig(config, options);
context.metadata.set(resolvedConfigKey, value); context.metadata.set(resolvedConfigKey, value);
@@ -383,6 +450,8 @@ export function authPlugin(options: AuthPluginOptions = {}) {
sessionCookieName: value.sessionCookieName, sessionCookieName: value.sessionCookieName,
onSessionVerification: value.onSessionVerification, onSessionVerification: value.onSessionVerification,
oauth: value.oauth,
oauthMfaPath: value.oauthMfaPath,
}); });
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir); 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, baseUrl: routeOptions.baseUrl ?? ctx.url.origin,
passkey: routeOptions.passkey, passkey: routeOptions.passkey,
onSessionVerification: routeOptions.onSessionVerification, 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" | "mfa"
| "sessions" | "sessions"
| "impersonation" | "impersonation"
| "passkeys"; | "passkeys"
| "oauth";
export interface AuthRouteDefinition { export interface AuthRouteDefinition {
path: string; path: string;
@@ -23,6 +24,24 @@ export interface AuthRouteDefinition {
/** Single source of truth for package-contributed auth endpoints. */ /** Single source of truth for package-contributed auth endpoints. */
export const AUTH_ROUTE_DEFINITIONS = [ 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", path: "/api/auth/session/verify",
group: "sessions", group: "sessions",
+3
View File
@@ -1,6 +1,7 @@
import type { AuthEngine } from "./engine.ts"; import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts"; import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSessionVerificationHandler } from "./types.ts"; import type { AuthSessionVerificationHandler } from "./types.ts";
import type { OAuthProvider } from "@wrnexus/oauth";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "./validation.ts"; import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "./validation.ts";
export interface DefaultAuthRouteOptions { export interface DefaultAuthRouteOptions {
@@ -9,6 +10,8 @@ export interface DefaultAuthRouteOptions {
passkey?: AuthPasskeyHttpOptions; passkey?: AuthPasskeyHttpOptions;
sessionCookieName?: string; sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler; onSessionVerification?: AuthSessionVerificationHandler;
oauth?: Record<string, OAuthProvider>;
oauthMfaPath?: string;
} }
interface AuthRuntimeState { interface AuthRuntimeState {
+7 -2
View File
@@ -403,7 +403,12 @@ export interface AuthRandom {
} }
export interface AuthEngineOptions { 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; secret: string;
issuer?: string; issuer?: string;
delivery?: AuthDeliveryProvider; 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; user: AuthPublicUser;
locals: Context["locals"] & { locals: Context["locals"] & {
authUser: AuthPublicUser; authUser: AuthPublicUser;
+1 -5
View File
@@ -160,11 +160,7 @@ export const mfaOtpRequestSchema = v.object({
}); });
export const mfaSchema = v.object({ export const mfaSchema = v.object({
mfaToken: v mfaToken: v.string().min(20, "MFA transaction is invalid").max(512).optional(),
.string()
.required("MFA transaction is missing")
.min(20, "MFA transaction is invalid")
.max(512),
method: v method: v
.string() .string()
.required("Choose a verification method") .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 { generateTotp } from "../src/totp/index.ts";
import type { AuthDeliveryMessage, PasskeyProvider } from "../src/types.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() { function fixture() {
let time = 1_720_000_000_000; let time = 1_720_000_000_000;
let seed = 11; 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 { expect, test } from "bun:test";
import type { Context } from "@wrnexus/core"; import type { Context } from "@wrnexus/core";
import { createPluginRunner } from "@wrnexus/plugin"; 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 { AUTH_ROUTE_DEFINITIONS } from "../src/routes/definitions.ts";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { createAuthEngine } from "../src/engine.ts"; 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); 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 () => { test("unconfigured automatic discovery fails closed for routes, middleware, and migrations", async () => {
const metadata = new Map<string, unknown>(); const metadata = new Map<string, unknown>();
const runner = createPluginRunner(authPlugin(), { const runner = createPluginRunner(authPlugin(), {
+12 -3
View File
@@ -1,15 +1,24 @@
{ {
"name": "@wrnexus/authz", "name": "@wrnexus/authz",
"version": "0.8.9", "version": "0.8.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./db": "./src/db.ts" "./db": "./src/db.ts",
"./plugin": "./src/plugin.ts"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@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 { export interface CatalogSource {
/** File or package that declared this module, used in conflict messages. */ /** File or package that declared this module, used in conflict messages. */
source: string; source: string;
module: AuthzModule; module: AuthzModule<never, never>;
} }
/** Structural equality for declaration metadata. Key order is irrelevant. */ /** 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, guardPermission,
filterCan, filterCan,
AUTHZ_LOCALS_KEY, AUTHZ_LOCALS_KEY,
getRequestAuthorization,
} from "./middleware.ts"; } 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 { export type {
AuthzScope, AuthzScope,
AuthzCatalog, AuthzCatalog,
@@ -169,3 +172,7 @@ export type {
} from "./types.ts"; } from "./types.ts";
export type { AuthorizeDecisionOptions } from "./advanced.ts"; export type { AuthorizeDecisionOptions } from "./advanced.ts";
export { generatePermissionTypes } from "./codegen.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 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 { interface RequestAuthz {
resolver: AuthzResolver; resolver: AuthzResolver;
/** /**
@@ -49,10 +70,39 @@ export function authzMiddleware(options: AuthzResolverOptions): Middleware {
byValue: new Map(), byValue: new Map(),
}; };
ctx.locals[AUTHZ_LOCALS_KEY] = request; 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(); 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: * 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. * 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", reason: "Resource unavailable",
at: Date.now(), at: Date.now(),
}); });
return Response.json( return forbiddenResponse();
{ ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
} }
} }
@@ -264,12 +311,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
); );
} }
return Response.json( return forbiddenResponse(result, options.exposeReason);
options.exposeReason
? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
: { ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
}; };
} }
+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-]+)+$/; 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 * Validate and freeze one authorization declaration. Called from
* `app/authz/<name>.ts` as the module's default export. * `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 permissions = module.permissions ?? {};
const roles = module.roles ?? {}; const roles = module.roles ?? {};
const policies = module.policies ?? {}; 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. */ /** One `app/authz/<name>.ts` declaration. */
export interface AuthzModule { export interface AuthzModule<Subject = any, Resource = any> {
permissions?: Record<string, PermissionMeta>; permissions?: Record<string, PermissionMeta>;
roles?: Record<string, string[]>; roles?: Record<string, string[]>;
policies?: Record<string, DecisionPolicy<never, never>>; policies?: Record<string, DecisionPolicy<Subject, Resource>>;
attributes?: Record<string, AttributeMeta>; attributes?: Record<string, AttributeMeta>;
/** permission id -> policy names that must pass for it. */ /** permission id -> policy names that must pass for it. */
bindings?: Record<string, string[]>; 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. */ /** The merged, frozen view of every declaration in the app. */
export interface AuthzCatalog { export interface AuthzCatalog {
permissions: ReadonlyMap<string, PermissionMeta>; 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", "name": "@wrnexus/cli",
"version": "0.8.48", "version": "0.8.49",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "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 .", "doctor": "wrnexus doctor .",
"analyze": "wrnexus analyze .", "analyze": "wrnexus analyze .",
"inspect": "wrnexus inspect packages .", "inspect": "wrnexus inspect packages .",
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check" "check": "wrnexus check ."
}, },
"dependencies": { "dependencies": {
"@wrnexus/ai": "${scaffoldFrameworkRange}", "@wrnexus/ai": "${scaffoldFrameworkRange}",
@@ -227,8 +227,13 @@ export default tseslint.config(
dist/ dist/
.wrnexus/ .wrnexus/
**/.wrnexus/ **/.wrnexus/
.superpowers/
.claude/
*.log *.log
CLAUDE.md CLAUDE.md
app/routes.gen.ts
app/types/*.generated.*
app/db/queries.gen.ts
`, `,
".editorconfig": `root = true ".editorconfig": `root = true
+3 -2
View File
@@ -14,7 +14,7 @@
* (`databases.<name>`), with files under `app/db/<name>/`. * (`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 { basename, join, resolve } from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { import {
@@ -23,6 +23,7 @@ import {
type PackageMigrationDefinition, type PackageMigrationDefinition,
} from "@wrnexus/plugin"; } from "@wrnexus/plugin";
import { loadAppConfig, type AppConfig } from "@wrnexus/styles"; import { loadAppConfig, type AppConfig } from "@wrnexus/styles";
import { writeGeneratedFile } from "./write-generated.ts";
import { import {
generateQueriesFile, generateQueriesFile,
analyzeMigrations, analyzeMigrations,
@@ -156,7 +157,7 @@ export async function regenerateQueries(
.flatMap((f) => parseQueries(readFileSync(join(queriesDir, f), "utf8"))); .flatMap((f) => parseQueries(readFileSync(join(queriesDir, f), "utf8")));
const refs = await loadModelRefs(dbBase); const refs = await loadModelRefs(dbBase);
const code = generateQueriesFile(queries, refs, dialectOf(driver)); 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; return queries.length;
} }
+9
View File
@@ -60,6 +60,7 @@ Usage:
wrnexus generate types [app-dir] Generate application-wide route/component/key types wrnexus generate types [app-dir] Generate application-wide route/component/key types
wrnexus routes [app-dir] Generate typed named routes wrnexus routes [app-dir] Generate typed named routes
wrnexus typecheck [app-dir] Generate types and check TypeScript plus every .wrn file 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 add <package...> Install Capacitor or Expo native packages
wrnexus mobile compile Compile .wrn pages into native Expo routes wrnexus mobile compile Compile .wrn pages into native Expo routes
wrnexus native list List cross-platform native capabilities wrnexus native list List cross-platform native capabilities
@@ -239,6 +240,14 @@ async function main(): Promise<void> {
else console.log("✓ Application types are valid"); else console.log("✓ Application types are valid");
break; 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": { case "eject": {
const { runEject } = await import("./eject.ts"); const { runEject } = await import("./eject.ts");
const args = rest.filter((a) => !a.startsWith("--")); const args = rest.filter((a) => !a.startsWith("--"));
+2 -2
View File
@@ -4,14 +4,14 @@
* startup; also exposed via `wrnexus generate routes`. * startup; also exposed via `wrnexus generate routes`.
*/ */
import { writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { buildRouter, generateRoutesFile } from "@wrnexus/router"; import { buildRouter, generateRoutesFile } from "@wrnexus/router";
import { writeGeneratedFile } from "./write-generated.ts";
/** Regenerate `app/routes.gen.ts`. Returns the number of page routes. */ /** Regenerate `app/routes.gen.ts`. Returns the number of page routes. */
export function regenerateRoutes(appDir: string): number { export function regenerateRoutes(appDir: string): number {
const router = buildRouter(appDir); const router = buildRouter(appDir);
const code = generateRoutesFile(router.pages); const code = generateRoutesFile(router.pages);
writeFileSync(join(appDir, "routes.gen.ts"), code, "utf8"); writeGeneratedFile(join(appDir, "routes.gen.ts"), code);
return router.pages.length; return router.pages.length;
} }
+3 -2
View File
@@ -9,6 +9,7 @@ import { generate, generateTargets } from "@wrnexus/compiler";
import { regenerateRoutes } from "./routes.ts"; import { regenerateRoutes } from "./routes.ts";
import { loadAppConfig } from "@wrnexus/styles"; import { loadAppConfig } from "@wrnexus/styles";
import { createPluginRunner, discoverPlugins, type PluginContributions } from "@wrnexus/plugin"; import { createPluginRunner, discoverPlugins, type PluginContributions } from "@wrnexus/plugin";
import { writeGeneratedFile } from "./write-generated.ts";
function files(root: string, predicate: (path: string) => boolean): string[] { function files(root: string, predicate: (path: string) => boolean): string[] {
if (!existsSync(root)) return []; if (!existsSync(root)) return [];
@@ -309,7 +310,7 @@ declare namespace WRNexusGenerated {
`; `;
mkdirSync(typeDir, { recursive: true }); mkdirSync(typeDir, { recursive: true });
const output = join(typeDir, "wrnexus.generated.d.ts"); 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 // `.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 // 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. // instead — only genuine `.ts`/`.tsx` sources are compiled and checked.
@@ -321,7 +322,7 @@ declare namespace WRNexusGenerated {
${apiBlockAssertions(pageAsts, apiContracts, app)} ${apiBlockAssertions(pageAsts, apiContracts, app)}
export {}; export {};
`; `;
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8"); writeGeneratedFile(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode);
writePluginArtifacts(root, pluginContributions); writePluginArtifacts(root, pluginContributions);
return { return {
file: relative(root, output).replace(/\\/g, "/"), 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.production).toBe("bun run build && bun run start");
expect(pkg.scripts.typecheck).toBe("tsc --noEmit"); expect(pkg.scripts.typecheck).toBe("tsc --noEmit");
expect(pkg.scripts.test).toBe("wrnexus test ."); expect(pkg.scripts.test).toBe("wrnexus test .");
expect(pkg.scripts.check).toBe( expect(pkg.scripts.check).toBe("wrnexus check .");
"bun run typecheck && bun run lint && bun run test && bun run format:check",
);
} finally { } finally {
rmSync(parent, { recursive: true, force: true }); rmSync(parent, { recursive: true, force: true });
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.8.11", "version": "0.8.12",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+41 -6
View File
@@ -22,7 +22,20 @@ import {
/** Translate a key for the active language, interpolating `{param}` placeholders. */ /** Translate a key for the active language, interpolating `{param}` placeholders. */
export type TFunction = (key: string, params?: Record<string, string | number>) => string; 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. */ /** The raw incoming web-standard Request. */
req: Request; req: Request;
/** Parsed URL of the request (pathname, query, etc.). */ /** 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). */ /** Translate a key for the active language (identity until the runtime sets it). */
t: TFunction; t: TFunction;
/** Dynamic route params, e.g. `/users/[id]` -> `{ id: "42" }`. */ /** Dynamic route params, e.g. `/users/[id]` -> `{ id: "42" }`. */
params: Record<string, string>; params: Params;
/** /**
* Per-request scratch space. Middleware can attach values here * Per-request scratch space. Middleware can attach values here
* (e.g. the authenticated user) and downstream handlers can read them. * (e.g. the authenticated user) and downstream handlers can read them.
*/ */
locals: Record<string, unknown>; 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 * The authenticated user for this request, or null when anonymous. Populated
* by the `sessionAuth` middleware (or `logIn`); read via `getUser(ctx)`. * by the `sessionAuth` middleware (or `logIn`); read via `getUser(ctx)`.
*/ */
user?: unknown; user?: User | null;
/** Active tenant/workspace resolved by tenant middleware. */ /** Active tenant/workspace resolved by tenant middleware. */
tenant?: Tenant; tenant?: Tenant;
/** Request tracer installed by observability middleware. */ /** Request tracer installed by observability middleware. */
@@ -59,7 +74,9 @@ export type Context = {
session: SessionStore; session: SessionStore;
/** Read-only localStorage snapshot sent by the browser for CSR data bindings. */ /** Read-only localStorage snapshot sent by the browser for CSR data bindings. */
localStorage: LocalStorageSnapshot; 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). */ /** Calls the next middleware in the chain (or the final route handler). */
export type Next = () => Promise<Response> | Response; export type Next = () => Promise<Response> | Response;
@@ -99,12 +116,15 @@ export type PageMeta = SeoConfig;
export type PageComponent = (ctx: Context) => string | Promise<string>; export type PageComponent = (ctx: Context) => string | Promise<string>;
/** Create a fresh context for an incoming request. */ /** 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); const cookies = createCookieStore(req);
return { return {
req, req,
url, url,
params: {}, params: {} as Params,
locals: {}, locals: {},
lang: "", lang: "",
t: (key) => key, 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). // cookies get `Secure` behind a TLS-terminating proxy (matches CSRF cookies).
session: createSessionStore(cookies, req, undefined, url.protocol === "https:"), session: createSessionStore(cookies, req, undefined, url.protocol === "https:"),
localStorage: createLocalStorageSnapshot(req), 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. */ /** Apply headers accumulated on the context, such as Set-Cookie. */
export function withContextHeaders(ctx: Context, res: Response): Response { export function withContextHeaders(ctx: Context, res: Response): Response {
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
+29
View File
@@ -33,6 +33,10 @@ export interface EndpointDefinition<I, O> {
input?: SchemaLike<I> | OutputSchemaLike<I>; input?: SchemaLike<I> | OutputSchemaLike<I>;
output?: SchemaLike<O> | OutputSchemaLike<O>; output?: SchemaLike<O> | OutputSchemaLike<O>;
auth?: "optional" | "required"; 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; description?: string;
tags?: string[]; tags?: string[];
handler(input: I, ctx: Context): O | Promise<O>; handler(input: I, ctx: Context): O | Promise<O>;
@@ -111,6 +115,31 @@ export function defineEndpoint(
: await ctx.req.json().catch(() => ({})); : await ctx.req.json().catch(() => ({}));
input = schemaValue(definition.input, resolvedInput); 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 rawOutput = await definition.handler(input, ctx);
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput; const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
return output instanceof Response ? output : json({ data: output }); return output instanceof Response ? output : json({ data: output });
+3
View File
@@ -9,6 +9,8 @@ export type {
PageComponent, PageComponent,
SeoConfig, SeoConfig,
TFunction, TFunction,
PaginationOptions,
PaginationInput,
} from "./context.ts"; } from "./context.ts";
export { createContext, withContextHeaders } from "./context.ts"; export { createContext, withContextHeaders } from "./context.ts";
export { export {
@@ -127,6 +129,7 @@ export {
export type { export type {
CookieOptions, CookieOptions,
CookieStore, CookieStore,
TransactionCookie,
LocalStorageSnapshot, LocalStorageSnapshot,
SessionStore, SessionStore,
SessionBackend, SessionBackend,
+49 -1
View File
@@ -17,6 +17,16 @@ export interface CookieStore {
set(name: string, value: string, options?: CookieOptions): void; set(name: string, value: string, options?: CookieOptions): void;
delete(name: string, options?: CookieOptions): void; delete(name: string, options?: CookieOptions): void;
headers(): string[]; 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 { export interface SessionStore {
@@ -259,7 +269,7 @@ export function createCookieStore(req: Request): CookieStore {
const incoming = parseCookieHeader(req.headers.get("cookie") ?? ""); const incoming = parseCookieHeader(req.headers.get("cookie") ?? "");
const outgoing: string[] = []; const outgoing: string[] = [];
return { const store: CookieStore = {
get(name) { get(name) {
return incoming[name]; return incoming[name];
}, },
@@ -287,7 +297,45 @@ export function createCookieStore(req: Request): CookieStore {
headers() { headers() {
return [...outgoing]; 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( 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/db", "name": "@wrnexus/db",
"version": "0.8.17", "version": "0.8.18",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
+28
View File
@@ -63,6 +63,34 @@ export function withTransaction<T>(db: Db, callback: (tx: Db) => Promise<T>): Pr
return db.tx(callback); 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. */ /** Conservative default classifier for deadlock/serialization retry errors. */
export function isRetryableTransactionError(error: unknown): boolean { export function isRetryableTransactionError(error: unknown): boolean {
if (!error || typeof error !== "object") return false; if (!error || typeof error !== "object") return false;
+1
View File
@@ -54,6 +54,7 @@ export {
exists, exists,
countRows, countRows,
withTransaction, withTransaction,
compareAndSet,
retryTransaction, retryTransaction,
batch, batch,
databaseHealth, databaseHealth,
+12
View File
@@ -5,6 +5,7 @@ import {
createRepository, createRepository,
databaseHealth, databaseHealth,
retryTransaction, retryTransaction,
compareAndSet,
} from "../src/index.ts"; } from "../src/index.ts";
import type { Driver, Row } from "../src/index.ts"; import type { Driver, Row } from "../src/index.ts";
@@ -28,6 +29,17 @@ function memoryDriver(): Driver {
} }
describe("database helper kit", () => { 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 () => { test("provides repository CRUD helpers and health checks", async () => {
const db = createDb(memoryDriver()); const db = createDb(memoryDriver());
const repository = createRepository<{ id: number; name: string }>(db, { const repository = createRepository<{ id: number; name: string }>(db, {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.8.44", "version": "0.8.45",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "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; 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 storeContainer = requestStoreContainer(ctx.req, matched.route.raw);
const mod = await loadModule(matched.route.file); 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") { if (ctx.req.method.toUpperCase() === "POST") {
const actionResponse = await handlePageAction(ctx, mod); const actionResponse = await handlePageAction(ctx, mod);
if (actionResponse) return actionResponse; 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`); throw new Error(`Page ${matched.route.file} has no default export`);
} }
ctx.params = matched.params;
const meta = (mod.meta ?? {}) as PageMeta; const meta = (mod.meta ?? {}) as PageMeta;
const pageNavigation = (mod.__wrnexusNavigation ?? {}) as { preserve?: string }; const pageNavigation = (mod.__wrnexusNavigation ?? {}) as { preserve?: string };
const pageCache = (mod.__wrnexusCache ?? {}) as Record<string, string>; const pageCache = (mod.__wrnexusCache ?? {}) as Record<string, string>;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/queue", "name": "@wrnexus/queue",
"version": "0.8.8", "version": "0.8.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+25 -4
View File
@@ -14,6 +14,8 @@ export interface QueueStore {
remove(id: string): Promise<void>; remove(id: string): Promise<void>;
due(now: number, limit: number): Promise<Job[]>; due(now: number, limit: number): Promise<Job[]>;
list(name?: string): 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>; claim?(id: string, worker: string, leaseUntil: number): Promise<boolean>;
} }
@@ -48,6 +50,15 @@ export function memoryQueueStore(): QueueStore {
.filter((job) => !name || job.name === name) .filter((job) => !name || job.name === name)
.map((job) => structuredClone(job)); .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[]>; list(name?: string): Promise<Job[]>;
failed(): Job[]; failed(): Job[];
retry(id: string): Promise<boolean>; 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 { function positiveInteger(value: number, label: string): number {
@@ -178,12 +193,12 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
} }
if (add.idempotencyKey) { if (add.idempotencyKey) {
const existing = (await store.list(name)).find( const existing = store.findByIdempotencyKey
(job) => job.idempotencyKey === add.idempotencyKey, ? await store.findByIdempotencyKey(name, add.idempotencyKey)
); : (await store.list(name)).find((job) => job.idempotencyKey === add.idempotencyKey);
if (existing) return existing as Job<T>; 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`); throw new Error(`WRN-QUEUE-CAPACITY: queue capacity of ${capacity} reached`);
} }
@@ -204,6 +219,12 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
return structuredClone(job); 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) { process(name, handler) {
if (!name.trim()) throw new TypeError("queue worker name cannot be empty"); if (!name.trim()) throw new TypeError("queue worker name cannot be empty");
handlers.set(name, handler as JobHandler); handlers.set(name, handler as JobHandler);
+3
View File
@@ -92,6 +92,9 @@ export function defineJob<I>(definition: JobDefinition<I>): JobDefinition<I> {
return definition; return definition;
} }
export { defineWorker, runWorker } from "./worker.ts";
export type { WorkerDefinition } from "./worker.ts";
export interface WorkflowStep<I, O> { export interface WorkflowStep<I, O> {
name: string; name: string;
run(input: I): O | Promise<O>; 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) .map((value) => JSON.parse(value) as Job)
.filter((job) => !name || job.name === name); .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) { async claim(id, worker, leaseUntil) {
const ttl = Math.max(1, leaseUntil - Date.now()); const ttl = Math.max(1, leaseUntil - Date.now());
return Boolean(await client.set(`${prefix}:lease:${id}`, worker, { NX: true, PX: ttl })); 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); 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) { async claim(id, worker, leaseUntil) {
const result = await db.query( 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`, `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, cronToInterval,
defineWorkflow, defineWorkflow,
memoryQueueStore, memoryQueueStore,
defineWorker,
runWorker,
} from "../src/index.ts"; } 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 () => { test("processes a job", async () => {
const queue = createQueue(); const queue = createQueue();
const done: string[] = []; const done: string[] = [];
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/styles", "name": "@wrnexus/styles",
"version": "0.8.16", "version": "0.8.17",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+16 -3
View File
@@ -330,6 +330,19 @@ export interface AppConfig {
profiles?: Record<string, Partial<Omit<AppConfig, "profiles">>>; 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"]; 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. */ /** 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(); const active = profile ?? resolveProfile();
// Configuration modules commonly read DATABASE_URL and other settings during // Configuration modules commonly read DATABASE_URL and other settings during
// module evaluation. Load the profile cascade before importing the module so // 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("; ")}`, `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; message: string;
} }
export function defineConfig(config: AppConfig): AppConfig { export function defineConfig<const T extends AppConfig>(config: T): T {
return config; return config;
} }
+2
View File
@@ -9,6 +9,8 @@
export type { export type {
AppConfig, AppConfig,
AppConfigInput,
ResolvedAppConfig,
BuildConfig, BuildConfig,
ConfigIssue, ConfigIssue,
DevToolbarConfig, DevToolbarConfig,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/syntax", "name": "@wrnexus/syntax",
"version": "0.8.12", "version": "0.8.13",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "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}`); throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
} }
switch (kw.value) { 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": { case "layout": {
// layout = "public" — selects app/layouts/<name>.wrn for this page. // layout = "public" — selects app/layouts/<name>.wrn for this page.
lx.next(); lx.next();
+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" });
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/test", "name": "@wrnexus/test",
"version": "0.8.9", "version": "0.8.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "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"> { export interface TestRequestOptions extends Omit<RequestInit, "body"> {
body?: BodyInit | Record<string, unknown> | URLSearchParams | FormData | null; body?: BodyInit | Record<string, unknown> | URLSearchParams | FormData | null;
baseUrl?: string; baseUrl?: string;
params?: Record<string, string>;
user?: unknown;
locals?: Record<string, unknown>;
} }
/** Build a web-standard Request with convenient JSON/FormData handling. */ /** 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. */ /** Create a complete Context suitable for middleware and route unit tests. */
export function testContext(path = "/", options: TestRequestOptions = {}): Context { export function testContext(path = "/", options: TestRequestOptions = {}): Context {
const request = testRequest(path, options); 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> { 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 { export {
testRequest, testRequest,
testContext, testContext,
createTestContext,
readJsonResponse, readJsonResponse,
expectProblem, expectProblem,
deferred, deferred,
waitFor, waitFor,
MemoryCookieJar, MemoryCookieJar,
createFetchMock,
} from "./advanced.ts";
export type {
TestRequestOptions,
JsonResponse,
Deferred,
WaitForOptions,
FetchMock,
} from "./advanced.ts"; } from "./advanced.ts";
export type { TestRequestOptions, JsonResponse, Deferred, WaitForOptions } from "./advanced.ts";
export { withDatabaseRollback, createFactory, captureBrowserArtifacts } from "./platform.ts"; export { withDatabaseRollback, createFactory, captureBrowserArtifacts } from "./platform.ts";
export type { TransactionalDatabase, BrowserArtifactPage } 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 { const COUNTER = `component Counter {
props { props {
@@ -30,3 +38,22 @@ test("callRoute invokes an API handler with a Context", async () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, path: "/api/ping", method: "POST" }); 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");
});