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
- Added framework-owned authentication and authorization integration: lazy auth stores,
configured OAuth routes with cookie-bound transactions, production configuration validation,
default lifecycle roles, typed request users, declarative API permissions, page guards, and
consistent unauthorized/forbidden responses.
- Added reusable application primitives for typed route params, bounded cursor pagination,
owned-resource authorization, atomic database state transitions, queue batching and explicit
worker lifecycles.
- Expanded `@wrnexus/test` with official typed contexts, Bun-compatible fetch mocking and a
full-stack harness alias; made generated files deterministic and introduced `wrnexus check` as
the canonical build-before-check command for generated applications.
- Fixed `v.boolean()` coercion in `@wrnexus/validation` (`checkField` in both `src/index.ts`
and the browser mirror in `src/runtime.ts`): previously any string other than `"true"` or
`"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g.
+11 -10
View File
@@ -235,7 +235,7 @@
},
"packages/auth": {
"name": "@wrnexus/auth",
"version": "0.8.14",
"version": "0.8.15",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/captcha": "workspace:*",
@@ -257,10 +257,11 @@
},
"packages/authz": {
"name": "@wrnexus/authz",
"version": "0.8.9",
"version": "0.8.10",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/db": "workspace:*",
"@wrnexus/plugin": "workspace:*",
},
},
"packages/benchmark": {
@@ -291,7 +292,7 @@
},
"packages/cli": {
"name": "@wrnexus/cli",
"version": "0.8.48",
"version": "0.8.49",
"bin": {
"wrnexus": "src/index.ts",
},
@@ -337,7 +338,7 @@
},
"packages/core": {
"name": "@wrnexus/core",
"version": "0.8.11",
"version": "0.8.12",
},
"packages/csr": {
"name": "@wrnexus/csr",
@@ -348,7 +349,7 @@
},
"packages/db": {
"name": "@wrnexus/db",
"version": "0.8.17",
"version": "0.8.18",
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^6.0.3",
@@ -356,7 +357,7 @@
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
"version": "0.8.44",
"version": "0.8.45",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*",
@@ -545,7 +546,7 @@
},
"packages/queue": {
"name": "@wrnexus/queue",
"version": "0.8.8",
"version": "0.8.9",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/rpc": "workspace:*",
@@ -632,7 +633,7 @@
},
"packages/styles": {
"name": "@wrnexus/styles",
"version": "0.8.16",
"version": "0.8.17",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
@@ -641,11 +642,11 @@
},
"packages/syntax": {
"name": "@wrnexus/syntax",
"version": "0.8.12",
"version": "0.8.13",
},
"packages/test": {
"name": "@wrnexus/test",
"version": "0.8.9",
"version": "0.8.10",
},
"packages/tracking": {
"name": "@wrnexus/tracking",
+39 -3
View File
@@ -91,6 +91,7 @@
"AuthPluginOptions",
"AuthPublicUser",
"AuthRandom",
"AuthRequiredError",
"AuthResult",
"AuthRiskDecision",
"AuthRiskLevel",
@@ -210,6 +211,7 @@
"signUpSchema",
"totpUri",
"tryGetDefaultAuthEngine",
"validateProductionAuthConfig",
"verificationRequestSchema",
"verificationTokenSchema",
"verifyTotp"
@@ -229,6 +231,7 @@
],
"./middleware": [
"AUTH_SESSION_KEY",
"AuthRequiredError",
"AuthSessionOptions",
"RequireAuthOptions",
"authSession",
@@ -237,7 +240,8 @@
"getAuthSession",
"getAuthUser",
"isAuthenticatedContext",
"requireAuth"
"requireAuth",
"requireAuthUser"
],
"./passkeys": [
"MemoryPasskeyChallengeStore",
@@ -252,11 +256,13 @@
"./plugin": [
"AuthAuditIssue",
"AuthConfig",
"AuthOAuthProviderConfig",
"AuthPluginOptions",
"AuthRoutesConfig",
"authComponentsDir",
"authPlugin",
"default"
"default",
"validateProductionAuthConfig"
],
"./protector": [
"createAuthSecretProtector"
@@ -460,10 +466,14 @@
"AUTHZ_LOCALS_KEY",
"AttributeMeta",
"AuthorizationDecision",
"AuthorizationResponses",
"AuthorizeDecisionOptions",
"AuthorizedHandlerOptions",
"AuthzAuditEvent",
"AuthzAuditSink",
"AuthzCatalog",
"AuthzConfig",
"AuthzIdentityEvent",
"AuthzModule",
"AuthzResolver",
"AuthzResolverOptions",
@@ -476,10 +486,12 @@
"GrantEffect",
"GuardOptions",
"MemoryAuditSink",
"OwnedResourceDefinition",
"PermissionMeta",
"PermissionStore",
"Policy",
"Rbac",
"RequestAuthorization",
"Subject",
"SubjectAssignments",
"all",
@@ -487,17 +499,21 @@
"allow",
"any",
"anyDecision",
"assignDefaultAuthzRoles",
"attr",
"authorize",
"authorizeDecision",
"authzMiddleware",
"authzPlugin",
"cachedPermissionStore",
"can",
"consoleAuditSink",
"createAuthzResolver",
"decideFor",
"decision",
"defineAuthorizedHandler",
"defineAuthz",
"defineOwnedResource",
"defineRbac",
"deniedBy",
"deny",
@@ -507,6 +523,7 @@
"filterCan",
"generatePermissionTypes",
"getAuthzCatalog",
"getRequestAuthorization",
"guardPermission",
"hasAuthzCatalog",
"hasRole",
@@ -519,12 +536,18 @@
"requireRole",
"safeRecord",
"scopeKey",
"setAuthzCatalog"
"setAuthzCatalog",
"setDefaultAuthzRoles"
],
"./db": [
"authzMigrationSql",
"dbPermissionStore",
"ensureAuthzTables"
],
"./plugin": [
"AuthzConfig",
"authzPlugin",
"default"
]
},
"@wrnexus/benchmark": {
@@ -1137,6 +1160,8 @@
"POSTGRES_TENANT_DIRECTORY_SCHEMA",
"PageComponent",
"PageMeta",
"PaginationInput",
"PaginationOptions",
"PerformanceBudgets",
"PerformanceMeasurement",
"PermissionsPolicyConfig",
@@ -1198,6 +1223,7 @@
"TenantResource",
"TenantSqlClient",
"Tracer",
"TransactionCookie",
"TrustedTypesConfig",
"UploadError",
"UploadInspectionResult",
@@ -1380,6 +1406,7 @@
"applyMigrations",
"batch",
"closeDatabases",
"compareAndSet",
"countRows",
"createDb",
"createRepository",
@@ -2260,6 +2287,7 @@
"SqlQueueClient",
"SubjectJob",
"SubjectQueue",
"WorkerDefinition",
"WorkflowDefinition",
"WorkflowEngine",
"WorkflowRunContext",
@@ -2275,6 +2303,7 @@
"cronToInterval",
"defineDurableWorkflow",
"defineJob",
"defineWorker",
"defineWorkflow",
"memoryQueueStore",
"memoryWorkflowStore",
@@ -2283,6 +2312,7 @@
"redisQueueStore",
"renderQueueDashboard",
"runQueueDaemon",
"runWorker",
"subjectQueue"
]
},
@@ -2639,6 +2669,7 @@
".": [
"ACCENT_COOKIE",
"AppConfig",
"AppConfigInput",
"BrowserCookieApi",
"BrowserCookieOptions",
"BrowserCookiePreference",
@@ -2663,6 +2694,7 @@
"ObservabilityConfig",
"PerformanceConfig",
"PwaConfig",
"ResolvedAppConfig",
"ResolvedConfigLayers",
"ResolvedTheme",
"StyleProcessContext",
@@ -2877,6 +2909,7 @@
".": [
"BrowserArtifactPage",
"Deferred",
"FetchMock",
"Harness",
"HarnessOptions",
"JsonResponse",
@@ -2892,7 +2925,10 @@
"captureBrowserArtifacts",
"createContext",
"createFactory",
"createFetchMock",
"createHarness",
"createTestApp",
"createTestContext",
"deferred",
"describe",
"expect",
+20 -1
View File
@@ -1,6 +1,6 @@
"use strict";
// 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
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -6557,6 +6557,25 @@ function parse(source) {
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
}
switch (kw.value) {
case "auth": {
lx.next();
expect("eq");
const value = lx.next();
if (value.type !== "ident" || !["true", "false"].includes(value.value)) {
throw new ParseError(`Expected auth = true or false at offset ${value.pos}`);
}
security.auth = value.value === "true" ? "required" : "false";
break;
}
case "permission": {
lx.next();
expect("eq");
const value = expect("string").value.trim();
if (!value)
throw new ParseError(`Page permission cannot be empty at offset ${kw.pos}`);
security.permission = value;
break;
}
case "layout": {
// layout = "public" — selects app/layouts/<name>.wrn for this page.
lx.next();
+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
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
+20 -1
View File
@@ -1,5 +1,5 @@
#!/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
// @bun @bun-cjs
(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}`);
}
switch (kw.value) {
case "auth": {
lx.next();
expect("eq");
const value = lx.next();
if (value.type !== "ident" || !["true", "false"].includes(value.value)) {
throw new ParseError(`Expected auth = true or false at offset ${value.pos}`);
}
security.auth = value.value === "true" ? "required" : "false";
break;
}
case "permission": {
lx.next();
expect("eq");
const value = expect("string").value.trim();
if (!value)
throw new ParseError(`Page permission cannot be empty at offset ${kw.pos}`);
security.permission = value;
break;
}
case "layout": {
lx.next();
expect("eq");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.8.14",
"version": "0.8.15",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
+23 -1
View File
@@ -266,7 +266,29 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (options.secret.length < MIN_SECRET_LENGTH) {
throw new TypeError(`auth secret must be at least ${MIN_SECRET_LENGTH} characters`);
}
const store = options.store;
let resolvedStore: AuthStore | undefined;
const resolveStore = (): AuthStore => {
if (resolvedStore) return resolvedStore;
resolvedStore = typeof options.store === "function" ? options.store() : options.store;
if (!resolvedStore || typeof resolvedStore !== "object") {
throw new TypeError("WRN-AUTH-STORE: the auth store factory did not return an AuthStore");
}
return resolvedStore;
};
// AuthEngine.store remains source-compatible while deferring the factory
// until the first actual property read or method call.
const store = new Proxy({} as AuthStore, {
get(_target, property) {
const value = Reflect.get(resolveStore() as object, property);
return typeof value === "function" ? value.bind(resolveStore()) : value;
},
set(_target, property, value) {
return Reflect.set(resolveStore() as object, property, value);
},
has(_target, property) {
return Reflect.has(resolveStore() as object, property);
},
});
const now = () => {
const value = options.clock?.now() ?? Date.now();
if (!Number.isFinite(value)) throw new Error("WRN-AUTH-CLOCK: clock returned an invalid time");
+94 -2
View File
@@ -10,6 +10,8 @@ import {
} from "../middleware.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
import type { AuthSessionVerificationHandler } from "../types.ts";
import { completeAuth, startAuth, type OAuthProvider } from "@wrnexus/oauth";
import { assignDefaultAuthzRoles } from "@wrnexus/authz";
function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
@@ -50,6 +52,8 @@ export interface AuthHttpOptions {
schemas?: AuthSchemaOverrides | AuthSchemaSet;
passkey?: AuthPasskeyHttpOptions;
onSessionVerification?: AuthSessionVerificationHandler;
oauth?: Record<string, OAuthProvider>;
oauthMfaPath?: string;
}
export function createAuthHttpHandlers(options: AuthHttpOptions) {
@@ -59,6 +63,17 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const onSignedOut = engine.onSignedOut;
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
function oauthProvider(ctx: Context): OAuthProvider | undefined {
return options.oauth?.[String(ctx.params.provider ?? "").toLowerCase()];
}
function oauthRedirectUri(ctx: Context, provider: OAuthProvider): string {
return new URL(
`/api/auth/oauth/${encodeURIComponent(provider.name)}/callback`,
options.baseUrl ?? ctx.url.origin,
).toString();
}
function signupRedirect(ctx: Context, value: string | undefined, fallback: string): Response {
const path = safeAuthReturnTo(value, ctx.url.origin) ?? fallback;
return Response.redirect(new URL(path, ctx.url), 303);
@@ -80,6 +95,71 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
}
return {
async oauthProviders(ctx: Context): Promise<Response> {
return json({
ok: true,
providers: Object.values(options.oauth ?? {}).map((provider) => ({
id: provider.name,
name: provider.name,
label: `Continue with ${provider.name.charAt(0).toUpperCase()}${provider.name.slice(1)}`,
href: `/api/auth/oauth/${encodeURIComponent(provider.name)}?returnTo=${encodeURIComponent(
safeAuthReturnTo(ctx.url.searchParams.get("returnTo") ?? undefined, ctx.url.origin) ??
"/",
)}`,
})),
});
},
async startOAuth(ctx: Context): Promise<Response> {
const provider = oauthProvider(ctx);
if (!provider) return json({ ok: false, error: "OAuth provider not configured" }, 404);
const returnTo =
safeAuthReturnTo(ctx.url.searchParams.get("returnTo") ?? undefined, ctx.url.origin) ?? "/";
const started = await startAuth(provider, { redirectUri: oauthRedirectUri(ctx, provider) });
ctx.cookies.transaction(`wrnexus.oauth.${provider.name}`).set({
state: started.state,
verifier: started.verifier,
returnTo,
});
return Response.redirect(started.url, 302);
},
async completeOAuth(ctx: Context): Promise<Response> {
const provider = oauthProvider(ctx);
if (!provider) return json({ ok: false, error: "OAuth provider not configured" }, 404);
const transaction = ctx.cookies
.transaction<{
state: string;
verifier: string;
returnTo: string;
}>(`wrnexus.oauth.${provider.name}`)
.consume();
const state = ctx.url.searchParams.get("state");
const code = ctx.url.searchParams.get("code");
if (!transaction || !state || transaction.state !== state || !code) {
return json({ ok: false, error: "OAuth transaction is invalid or expired" }, 400);
}
const completed = await completeAuth(provider, {
code,
verifier: transaction.verifier,
redirectUri: oauthRedirectUri(ctx, provider),
});
const result = await engine.loginWithOAuth(
provider.name,
completed.profile,
completed.tokens,
);
if (result.code === "mfa-required" && result.mfaToken) {
ctx.cookies.transaction("wrnexus.auth.mfa", { sameSite: "Lax", maxAge: 300 }).set({
mfaToken: result.mfaToken,
returnTo: transaction.returnTo,
});
return Response.redirect(new URL(options.oauthMfaPath ?? "/two-factor", ctx.url), 303);
}
if (!result.ok || !result.session || !result.user) return json(result, 401);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) return onSignedIn(ctx, transaction.returnTo);
return Response.redirect(new URL(transaction.returnTo, ctx.url), 303);
},
async verifySession(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (options.onSessionVerification) {
@@ -110,6 +190,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
});
if (!result.ok || !result.user) return json(result, 400);
await assignDefaultAuthzRoles(result.user.id, "signup");
const action = await onSuccessfulSignUp?.(ctx, result.user);
if (action instanceof Response) return action;
if (action?.autoSignIn) {
@@ -230,6 +312,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
password: text(input.password) || undefined,
displayName: text(input.displayName) || undefined,
});
if (result.ok && result.user) await assignDefaultAuthzRoles(result.user.id, "invitation");
return json(result, result.ok ? 200 : 400);
},
@@ -403,12 +486,18 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const validation = await parseBody(schemas.mfaComplete, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const continuation = ctx.cookies
.transaction<{ mfaToken: string; returnTo?: string }>("wrnexus.auth.mfa", {
sameSite: "Lax",
maxAge: 300,
})
.consume();
const methodValue = text(input.method);
const method = ["totp", "recovery-code", "email-otp", "sms-otp"].includes(methodValue)
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
: "totp";
const result = await engine.completeMfa({
mfaToken: text(input.mfaToken),
mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
method,
code: text(input.code),
challengeId: text(input.challengeId) || undefined,
@@ -421,7 +510,10 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
return onSignedIn(
ctx,
safeAuthReturnTo(text(input.returnTo) || continuation?.returnTo, ctx.url.origin),
);
}
return json(result);
},
+3
View File
@@ -9,6 +9,8 @@ export {
clearAuthSession,
getAuthUser,
getAuthSession,
requireAuthUser,
AuthRequiredError,
isAuthenticatedContext,
AUTH_SESSION_KEY,
} from "./middleware.ts";
@@ -20,6 +22,7 @@ export {
export {
authPlugin,
authComponentsDir,
validateProductionAuthConfig,
type AuthConfig,
type AuthRoutesConfig,
type AuthPluginOptions,
+17
View File
@@ -79,6 +79,23 @@ export function getAuthUser(ctx: Context): AuthPublicUser | null {
);
}
/** Return a typed authenticated user or fail closed for direct handler use. */
export function requireAuthUser(ctx: Context): AuthPublicUser {
const user = getAuthUser(ctx);
if (!user) throw new AuthRequiredError();
return user;
}
export class AuthRequiredError extends Error {
readonly code = "WRN-AUTH-REQUIRED";
readonly status = 401;
constructor() {
super("Authentication is required");
this.name = "AuthRequiredError";
}
}
export function getAuthSession(ctx: Context): AuthSession | null {
return (ctx.locals.authSession as AuthSession | undefined) ?? null;
}
+69
View File
@@ -2,6 +2,13 @@ import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import {
discord,
github,
google,
type OAuthProvider,
type ProviderCredentials,
} from "@wrnexus/oauth";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSessionVerificationHandler } from "./types.ts";
@@ -32,8 +39,11 @@ export interface AuthRoutesConfig {
sessions?: boolean;
impersonation?: boolean;
passkeys?: boolean;
oauth?: boolean;
}
export type AuthOAuthProviderConfig = OAuthProvider | ProviderCredentials;
export interface AuthConfig {
enabled?: boolean;
engine?: AuthEngine;
@@ -48,12 +58,36 @@ export interface AuthConfig {
baseUrl?: string;
csrf?: boolean;
passkey?: AuthPasskeyHttpOptions;
oauth?: Partial<Record<"google" | "github" | "discord", AuthOAuthProviderConfig>> &
Record<string, AuthOAuthProviderConfig>;
oauthMfaPath?: string;
/** Disable only when an external deployment gate performs equivalent checks. */
productionValidation?: boolean;
/** Shared SSO cookie whose value is an AuthEngine session id. */
sessionCookieName?: string;
/** Customize the package forward-auth verification response. */
onSessionVerification?: AuthSessionVerificationHandler;
}
export function validateProductionAuthConfig(
config: Pick<AuthConfig, "baseUrl" | "oauth">,
env: Record<string, string | undefined> = process.env,
): void {
const secret = env.AUTH_SECRET?.trim() ?? "";
if (secret.length < 32 || /^(?:change-me|development|test-only)/i.test(secret)) {
throw new Error(
"WRN-AUTH-CONFIG: AUTH_SECRET must be a non-development secret of at least 32 characters in production",
);
}
if (!config.baseUrl) throw new Error("WRN-AUTH-CONFIG: auth.baseUrl is required in production");
const base = new URL(config.baseUrl);
if (base.protocol !== "https:")
throw new Error("WRN-AUTH-CONFIG: auth.baseUrl must use HTTPS in production");
if (["localhost", "127.0.0.1", "::1"].includes(base.hostname)) {
throw new Error("WRN-AUTH-CONFIG: auth.baseUrl must not use localhost in production");
}
}
/** Explicit plugin options remain supported for compatibility. Prefer config.auth. */
export interface AuthPluginOptions {
componentDir?: string;
@@ -88,6 +122,8 @@ interface ResolvedAuthConfig {
passkey?: AuthPasskeyHttpOptions;
sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler;
oauth: Record<string, OAuthProvider>;
oauthMfaPath?: string;
}
const moduleRoot = dirname(fileURLToPath(import.meta.url));
@@ -121,6 +157,24 @@ function resolveConfig(
const enabled = raw.enabled !== false;
const hasEngine = Boolean(raw.engine);
const hasDefaultDb = Boolean(config.db);
const oauth: Record<string, OAuthProvider> = {};
for (const [name, provider] of Object.entries(raw.oauth ?? {})) {
if (!provider || !provider.clientId?.trim() || !provider.clientSecret?.trim()) continue;
oauth[name] =
"authorizeUrl" in provider
? provider
: name === "google"
? google(provider)
: name === "github"
? github(provider)
: name === "discord"
? discord(provider)
: (() => {
throw new Error(
`WRN-AUTH-OAUTH-CONFIG: custom provider '${name}' requires a complete OAuthProvider`,
);
})();
}
return {
enabled,
@@ -148,6 +202,8 @@ function resolveConfig(
passkey: raw.passkey,
sessionCookieName: raw.sessionCookieName,
onSessionVerification: raw.onSessionVerification,
oauth,
oauthMfaPath: raw.oauthMfaPath,
};
}
@@ -176,6 +232,7 @@ function fallbackConfig(options: AuthPluginOptions): ResolvedAuthConfig {
schemas: resolveAuthSchemas(),
csrf: true,
oauth: {},
};
}
@@ -353,6 +410,16 @@ export function authPlugin(options: AuthPluginOptions = {}) {
},
configure(config, context) {
const raw = (config.auth ?? {}) as AuthConfig;
if (
context.mode === "production" &&
process.env.NODE_ENV === "production" &&
raw.enabled !== false &&
raw.engine &&
raw.productionValidation !== false
) {
validateProductionAuthConfig(raw);
}
const value = resolveConfig(config, options);
context.metadata.set(resolvedConfigKey, value);
@@ -383,6 +450,8 @@ export function authPlugin(options: AuthPluginOptions = {}) {
sessionCookieName: value.sessionCookieName,
onSessionVerification: value.onSessionVerification,
oauth: value.oauth,
oauthMfaPath: value.oauthMfaPath,
});
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir);
+2
View File
@@ -61,6 +61,8 @@ function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
baseUrl: routeOptions.baseUrl ?? ctx.url.origin,
passkey: routeOptions.passkey,
onSessionVerification: routeOptions.onSessionVerification,
oauth: routeOptions.oauth,
oauthMfaPath: routeOptions.oauthMfaPath,
});
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("completeOAuth", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("startOAuth", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("oauthProviders", ctx);
}
+20 -1
View File
@@ -12,7 +12,8 @@ export type AuthRouteGroup =
| "mfa"
| "sessions"
| "impersonation"
| "passkeys";
| "passkeys"
| "oauth";
export interface AuthRouteDefinition {
path: string;
@@ -23,6 +24,24 @@ export interface AuthRouteDefinition {
/** Single source of truth for package-contributed auth endpoints. */
export const AUTH_ROUTE_DEFINITIONS = [
{
path: "/api/auth/oauth/providers",
group: "oauth",
handler: "oauthProviders",
methods: ["GET"],
},
{
path: "/api/auth/oauth/[provider]",
group: "oauth",
handler: "startOAuth",
methods: ["GET"],
},
{
path: "/api/auth/oauth/[provider]/callback",
group: "oauth",
handler: "completeOAuth",
methods: ["GET"],
},
{
path: "/api/auth/session/verify",
group: "sessions",
+3
View File
@@ -1,6 +1,7 @@
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSessionVerificationHandler } from "./types.ts";
import type { OAuthProvider } from "@wrnexus/oauth";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "./validation.ts";
export interface DefaultAuthRouteOptions {
@@ -9,6 +10,8 @@ export interface DefaultAuthRouteOptions {
passkey?: AuthPasskeyHttpOptions;
sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler;
oauth?: Record<string, OAuthProvider>;
oauthMfaPath?: string;
}
interface AuthRuntimeState {
+7 -2
View File
@@ -403,7 +403,12 @@ export interface AuthRandom {
}
export interface AuthEngineOptions {
store: import("./store.ts").AuthStore;
/**
* Authentication storage may be created lazily. This is the preferred form
* for database-backed stores because application configuration is imported
* before the runtime database connection is installed.
*/
store: import("./store.ts").AuthStore | (() => import("./store.ts").AuthStore);
secret: string;
issuer?: string;
delivery?: AuthDeliveryProvider;
@@ -501,7 +506,7 @@ export interface AuthResult {
};
}
export interface AuthenticatedContext extends Context {
export interface AuthenticatedContext extends Context<Record<string, string>, AuthPublicUser> {
user: AuthPublicUser;
locals: Context["locals"] & {
authUser: AuthPublicUser;
+1 -5
View File
@@ -160,11 +160,7 @@ export const mfaOtpRequestSchema = v.object({
});
export const mfaSchema = v.object({
mfaToken: v
.string()
.required("MFA transaction is missing")
.min(20, "MFA transaction is invalid")
.max(512),
mfaToken: v.string().min(20, "MFA transaction is invalid").max(512).optional(),
method: v
.string()
.required("Choose a verification method")
+19
View File
@@ -4,6 +4,25 @@ import { MemoryAuthStore } from "../src/stores/memory.ts";
import { generateTotp } from "../src/totp/index.ts";
import type { AuthDeliveryMessage, PasskeyProvider } from "../src/types.ts";
test("authentication storage factories resolve lazily and only once", async () => {
let calls = 0;
const backing = new MemoryAuthStore();
const engine = createAuthEngine({
store: () => {
calls++;
return backing;
},
secret: "a secure test secret that is longer than thirty-two characters",
});
expect(calls).toBe(0);
expect(engine.store).toBeDefined();
expect(calls).toBe(0);
await engine.getUser("missing");
await engine.getUser("still-missing");
expect(calls).toBe(1);
});
function fixture() {
let time = 1_720_000_000_000;
let seed = 11;
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, expect, test } from "bun:test";
import { createContext, withContextHeaders } from "@wrnexus/core";
import type { OAuthProvider } from "@wrnexus/oauth";
import { createAuthEngine } from "../src/engine.ts";
import { createAuthHttpHandlers } from "../src/http/index.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
test("configured OAuth owns PKCE state, callback, and session establishment", async () => {
const provider: OAuthProvider = {
name: "example",
authorizeUrl: "https://identity.example/authorize",
tokenUrl: "https://identity.example/token",
userInfoUrl: "https://identity.example/user",
scopes: ["openid", "email"],
clientId: "client",
clientSecret: "secret",
mapProfile: (raw) => ({
id: String(raw.id),
email: String(raw.email),
raw,
}),
};
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "oauth-http-secret-that-is-longer-than-thirty-two-characters",
});
const handlers = createAuthHttpHandlers({
engine,
baseUrl: "https://app.example",
oauth: { example: provider },
});
const startRequest = new Request(
"https://app.example/api/auth/oauth/example?returnTo=%2Fdashboard",
);
const startCtx = createContext(startRequest, new URL(startRequest.url));
startCtx.params = { provider: "example" };
const start = await handlers.startOAuth(startCtx);
expect(start.status).toBe(302);
const location = new URL(start.headers.get("location")!);
expect(location.searchParams.get("code_challenge_method")).toBe("S256");
const issued = withContextHeaders(startCtx, start).headers.get("set-cookie")!;
const replies = [
Response.json({ access_token: "access", token_type: "Bearer" }),
Response.json({ id: "provider-user", email: "oauth@example.test" }),
];
const mock: typeof fetch = Object.assign(async () => replies.shift()!, {
preconnect: () => undefined,
});
globalThis.fetch = mock;
const callbackRequest = new Request(
`https://app.example/api/auth/oauth/example/callback?code=code&state=${encodeURIComponent(location.searchParams.get("state")!)}`,
{ headers: { cookie: issued.split(";", 1)[0]! } },
);
const callbackCtx = createContext(callbackRequest, new URL(callbackRequest.url));
callbackCtx.params = { provider: "example" };
const callback = await handlers.completeOAuth(callbackCtx);
expect(callback.status).toBe(303);
expect(callback.headers.get("location")).toBe("https://app.example/dashboard");
expect(callbackCtx.user).toMatchObject({ id: expect.any(String) });
});
+16 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { createPluginRunner } from "@wrnexus/plugin";
import { authPlugin } from "../src/plugin.ts";
import { authPlugin, validateProductionAuthConfig } from "../src/plugin.ts";
import { AUTH_ROUTE_DEFINITIONS } from "../src/routes/definitions.ts";
import { readFileSync } from "node:fs";
import { createAuthEngine } from "../src/engine.ts";
@@ -65,6 +65,21 @@ test("plugin contributes components, runtime, styles, migration, and toolbar", a
expect(contributions.middleware).toHaveLength(1);
});
test("production auth validation rejects development secrets and origins", () => {
expect(() =>
validateProductionAuthConfig(
{ baseUrl: "http://localhost:3000" },
{ AUTH_SECRET: "change-me" },
),
).toThrow("AUTH_SECRET");
expect(() =>
validateProductionAuthConfig(
{ baseUrl: "http://app.example" },
{ AUTH_SECRET: "a-production-secret-that-is-longer-than-thirty-two-characters" },
),
).toThrow("HTTPS");
});
test("unconfigured automatic discovery fails closed for routes, middleware, and migrations", async () => {
const metadata = new Map<string, unknown>();
const runner = createPluginRunner(authPlugin(), {
+12 -3
View File
@@ -1,15 +1,24 @@
{
"name": "@wrnexus/authz",
"version": "0.8.9",
"version": "0.8.10",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./db": "./src/db.ts"
"./db": "./src/db.ts",
"./plugin": "./src/plugin.ts"
},
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/db": "workspace:*"
"@wrnexus/db": "workspace:*",
"@wrnexus/plugin": "workspace:*"
},
"wrnexus": {
"plugin": {
"plugin": "./src/plugin.ts",
"export": "default",
"factory": true
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { DecisionPolicy } from "./advanced.ts";
export interface CatalogSource {
/** File or package that declared this module, used in conflict messages. */
source: string;
module: AuthzModule;
module: AuthzModule<never, never>;
}
/** Structural equality for declaration metadata. Key order is irrelevant. */
+40
View File
@@ -0,0 +1,40 @@
import { getDb } from "@wrnexus/db";
import { dbPermissionStore } from "./db.ts";
import type { PermissionStore } from "./store.ts";
export type AuthzIdentityEvent = "signup" | "invitation";
interface DefaultRoleState {
signup: string[];
invitation: string[];
store?: PermissionStore;
}
const key = Symbol.for("@wrnexus/authz:default-roles:v1");
function state(): DefaultRoleState {
const global = globalThis as Record<PropertyKey, unknown>;
return (global[key] ??= { signup: [], invitation: [] }) as DefaultRoleState;
}
export function setDefaultAuthzRoles(roles: Partial<DefaultRoleState>): void {
state().signup = [...(roles.signup ?? [])];
state().invitation = [...(roles.invitation ?? [])];
}
/** Bind lifecycle role assignment to the same store used by authorization middleware. */
export function setDefaultAuthzRoleStore(store: PermissionStore): void {
state().store = store;
}
/** Assign configured identity lifecycle roles through the framework-owned store. */
export async function assignDefaultAuthzRoles(
subjectId: string,
event: AuthzIdentityEvent,
): Promise<void> {
if (!subjectId) return;
const roles = state()[event];
if (!roles.length) return;
const store = state().store ?? dbPermissionStore(getDb());
await Promise.all(roles.map((role) => store.assignRole(subjectId, role)));
}
+8 -1
View File
@@ -157,8 +157,11 @@ export {
guardPermission,
filterCan,
AUTHZ_LOCALS_KEY,
getRequestAuthorization,
} from "./middleware.ts";
export type { GuardOptions } from "./middleware.ts";
export type { GuardOptions, AuthorizationResponses, RequestAuthorization } from "./middleware.ts";
export { defineAuthorizedHandler, defineOwnedResource } from "./resource.ts";
export type { AuthorizedHandlerOptions, OwnedResourceDefinition } from "./resource.ts";
export type {
AuthzScope,
AuthzCatalog,
@@ -169,3 +172,7 @@ export type {
} from "./types.ts";
export type { AuthorizeDecisionOptions } from "./advanced.ts";
export { generatePermissionTypes } from "./codegen.ts";
export { authzPlugin } from "./plugin.ts";
export type { AuthzConfig } from "./plugin.ts";
export { assignDefaultAuthzRoles, setDefaultAuthzRoles } from "./defaults.ts";
export type { AuthzIdentityEvent } from "./defaults.ts";
+52 -10
View File
@@ -10,6 +10,27 @@ import type { AuthzScope } from "./types.ts";
*/
export const AUTHZ_LOCALS_KEY = "_authz";
export interface AuthorizationResponses {
forbidden(decision?: AuthorizationDecision, options?: { exposeReason?: boolean }): Response;
notFoundOrForbidden(options?: {
mayDiscover?: boolean;
decision?: AuthorizationDecision;
exposeReason?: boolean;
}): Response;
}
export interface RequestAuthorization extends AuthorizationResponses {
can(permission: string, resource?: unknown): Promise<boolean>;
decide(permission: string, resource?: unknown): Promise<AuthorizationDecision>;
}
declare module "@wrnexus/core" {
interface Context {
/** Installed by authzMiddleware for handlers that prefer context-local authorization. */
authz?: RequestAuthorization;
}
}
interface RequestAuthz {
resolver: AuthzResolver;
/**
@@ -49,10 +70,39 @@ export function authzMiddleware(options: AuthzResolverOptions): Middleware {
byValue: new Map(),
};
ctx.locals[AUTHZ_LOCALS_KEY] = request;
ctx.authz = {
can: (permission, resource) => can(ctx, permission, resource),
decide: (permission, resource) => decideFor(ctx, permission, resource),
forbidden: (decision, responseOptions) =>
forbiddenResponse(decision, responseOptions?.exposeReason),
notFoundOrForbidden: (responseOptions = {}) =>
responseOptions.mayDiscover
? Response.json(
{ ok: false, error: "Not Found" },
{ status: 404, headers: NO_STORE_HEADERS },
)
: forbiddenResponse(responseOptions.decision, responseOptions.exposeReason),
};
return next();
};
}
function forbiddenResponse(decision?: AuthorizationDecision, exposeReason = false): Response {
return Response.json(
exposeReason
? { ok: false, error: "Forbidden", reason: decision?.reason, policy: decision?.policy }
: { ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
}
export function getRequestAuthorization(ctx: Context): RequestAuthorization {
if (!ctx.authz) {
throw new Error("WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request.");
}
return ctx.authz;
}
/**
* Read the tenant from the context at decision time, not at middleware time:
* a request that switches tenant mid-flight must not keep the old scope.
@@ -236,10 +286,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
reason: "Resource unavailable",
at: Date.now(),
});
return Response.json(
{ ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
return forbiddenResponse();
}
}
@@ -264,12 +311,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
);
}
return Response.json(
options.exposeReason
? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
: { ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
return forbiddenResponse(result, options.exposeReason);
};
}
+97
View File
@@ -0,0 +1,97 @@
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import { authzMigrationSql } from "./migrations.ts";
import { setDefaultAuthzRoles } from "./defaults.ts";
export interface AuthzConfig {
enabled?: boolean;
declarations?: string;
store?: "database" | "memory";
migrations?: boolean;
middleware?: boolean;
strict?: boolean;
defaultRoles?: {
signup?: string[];
invitation?: string[];
};
}
interface ResolvedAuthzConfig {
enabled: boolean;
store: "database" | "memory";
migrations: boolean;
middleware: boolean;
strict: boolean;
defaultRoles: { signup: string[]; invitation: string[] };
driver: "sqlite" | "postgres" | "mysql";
}
const moduleRoot = dirname(fileURLToPath(import.meta.url));
const runtimeExtension = basename(moduleRoot) === "dist" ? ".js" : ".ts";
const middlewareEntry = join(moduleRoot, `runtime-middleware${runtimeExtension}`);
const memoryMiddlewareEntry = join(moduleRoot, `runtime-memory-middleware${runtimeExtension}`);
const metadataKey = "@wrnexus/authz:config";
function resolved(context: PluginContext): ResolvedAuthzConfig {
return (
(context.metadata.get(metadataKey) as ResolvedAuthzConfig | undefined) ?? {
enabled: false,
store: "database",
migrations: false,
middleware: false,
strict: true,
defaultRoles: { signup: [], invitation: [] },
driver: "sqlite",
}
);
}
export function authzPlugin() {
return definePlugin({
name: "@wrnexus/authz",
version: "0.8.0",
after: ["@wrnexus/auth"],
configure(config, context) {
const raw = (config.authz ?? {}) as AuthzConfig;
const enabled = raw.enabled !== false && Boolean(config.authz);
const db = config.db as { driver?: ResolvedAuthzConfig["driver"] } | undefined;
const value: ResolvedAuthzConfig = {
enabled,
store: raw.store ?? "database",
migrations: raw.migrations ?? enabled,
middleware: raw.middleware ?? enabled,
strict: raw.strict ?? true,
defaultRoles: {
signup: [...(raw.defaultRoles?.signup ?? [])],
invitation: [...(raw.defaultRoles?.invitation ?? [])],
},
driver: db?.driver ?? "sqlite",
};
if (enabled && value.store === "database" && !config.db) {
throw new Error("WRN-AUTHZ-CONFIG: authz.store='database' requires config.db");
}
context.metadata.set(metadataKey, value);
setDefaultAuthzRoles(value.defaultRoles);
config.authz = { ...raw, ...value };
},
middleware(context) {
const value = resolved(context);
if (!value.enabled || !value.middleware) return [];
return [value.store === "database" ? middlewareEntry : memoryMiddlewareEntry];
},
migrations(context) {
const value = resolved(context);
if (!value.enabled || !value.migrations || value.store !== "database") return [];
const sql = authzMigrationSql(value.driver);
return [
{
id: "wrnexus-authz-001",
source: `-- +up\n${sql.up.join(";\n")}\n;\n-- +down\n${sql.down.join(";\n")}\n;`,
},
];
},
});
}
export default authzPlugin;
+4 -2
View File
@@ -1,4 +1,4 @@
import type { AuthzModule } from "./types.ts";
import type { AuthzModule, DefinedAuthzModule } from "./types.ts";
const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
@@ -6,7 +6,9 @@ const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
* Validate and freeze one authorization declaration. Called from
* `app/authz/<name>.ts` as the module's default export.
*/
export function defineAuthz(module: AuthzModule): AuthzModule {
export function defineAuthz<Subject = any, Resource = any>(
module: AuthzModule<Subject, Resource>,
): DefinedAuthzModule<Subject, Resource> {
const permissions = module.permissions ?? {};
const roles = module.roles ?? {};
const policies = module.policies ?? {};
+59
View File
@@ -0,0 +1,59 @@
import type { Context } from "@wrnexus/core";
import { decideFor } from "./middleware.ts";
export interface AuthorizedHandlerOptions<Resource, Result extends Response = Response> {
permission: string;
resource?: (ctx: Context) => Resource | null | undefined | Promise<Resource | null | undefined>;
exposeReason?: boolean;
mayDiscover?: (ctx: Context) => boolean | Promise<boolean>;
handle(ctx: Context & { resource: Resource }): Result | Promise<Result>;
}
/** Define an API handler whose resource loading and permission check cannot be skipped. */
export function defineAuthorizedHandler<Resource = undefined, Result extends Response = Response>(
options: AuthorizedHandlerOptions<Resource, Result>,
): (ctx: Context) => Promise<Response> {
return async (ctx) => {
if (!ctx.user) return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const resource = options.resource ? await options.resource(ctx) : (undefined as Resource);
if (options.resource && resource == null) {
const mayDiscover = await options.mayDiscover?.(ctx);
return mayDiscover
? Response.json({ ok: false, error: "Not Found" }, { status: 404 })
: Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const decision = await decideFor(ctx, options.permission, resource);
if (!decision.allowed) {
return ctx.authz!.forbidden(decision, { exposeReason: options.exposeReason });
}
ctx.resource = resource;
return options.handle(ctx as Context & { resource: Resource });
};
}
export interface OwnedResourceDefinition<Resource> {
name: string;
owner(resource: Resource): string | null | undefined;
permissions: {
read?: string;
create?: string;
update?: string;
delete?: string;
readAny?: string;
writeAny?: string;
};
}
/** Shared ownership and override vocabulary for application resource modules. */
export function defineOwnedResource<Resource>(definition: OwnedResourceDefinition<Resource>) {
return Object.freeze({
...definition,
attributes(resource: Resource) {
return { ownerId: definition.owner(resource) };
},
isOwner(subjectId: string, resource: Resource) {
const ownerId = definition.owner(resource);
return Boolean(subjectId && ownerId && subjectId === ownerId);
},
});
}
@@ -0,0 +1,12 @@
import { getAuthzCatalog } from "./client.ts";
import { setDefaultAuthzRoleStore } from "./defaults.ts";
import { authzMiddleware } from "./middleware.ts";
import { memoryPermissionStore } from "./store.ts";
/** Process-local authorization storage for development and stateless tests. */
const store = memoryPermissionStore();
setDefaultAuthzRoleStore(store);
export default function frameworkMemoryAuthz(ctx: Context, next: Next) {
return authzMiddleware({ catalog: getAuthzCatalog(), store, strict: true })(ctx, next);
}
import type { Context, Next } from "@wrnexus/core";
+31
View File
@@ -0,0 +1,31 @@
import type { Context, Next } from "@wrnexus/core";
import { getDb, type Db } from "@wrnexus/db";
import { getAuthzCatalog } from "./client.ts";
import { dbPermissionStore, ensureAuthzTables } from "./db.ts";
import { setDefaultAuthzRoleStore } from "./defaults.ts";
import { authzMiddleware } from "./middleware.ts";
const initialized = new WeakMap<Db, Promise<void>>();
function initialize(db: Db): Promise<void> {
let pending = initialized.get(db);
if (!pending) {
pending = ensureAuthzTables(db);
initialized.set(db, pending);
pending.catch(() => initialized.delete(db));
}
return pending;
}
/** Package-installed authorization middleware with lazy database resolution. */
export default async function frameworkAuthz(ctx: Context, next: Next): Promise<Response> {
const db = getDb();
await initialize(db);
const store = dbPermissionStore(db);
setDefaultAuthzRoleStore(store);
return authzMiddleware({
catalog: getAuthzCatalog(),
store,
strict: true,
})(ctx, next);
}
+11 -2
View File
@@ -18,15 +18,24 @@ export interface AttributeMeta {
}
/** One `app/authz/<name>.ts` declaration. */
export interface AuthzModule {
export interface AuthzModule<Subject = any, Resource = any> {
permissions?: Record<string, PermissionMeta>;
roles?: Record<string, string[]>;
policies?: Record<string, DecisionPolicy<never, never>>;
policies?: Record<string, DecisionPolicy<Subject, Resource>>;
attributes?: Record<string, AttributeMeta>;
/** permission id -> policy names that must pass for it. */
bindings?: Record<string, string[]>;
}
/** A declaration after defineAuthz() has supplied all optional collections. */
export type DefinedAuthzModule<Subject = any, Resource = any> = {
permissions: Record<string, PermissionMeta>;
roles: Record<string, string[]>;
policies: Record<string, DecisionPolicy<Subject, Resource>>;
attributes: Record<string, AttributeMeta>;
bindings: Record<string, string[]>;
};
/** The merged, frozen view of every declaration in the app. */
export interface AuthzCatalog {
permissions: ReadonlyMap<string, PermissionMeta>;
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from "bun:test";
import { createPluginRunner } from "@wrnexus/plugin";
import { authzPlugin } from "../src/plugin.ts";
function runner() {
return createPluginRunner(authzPlugin(), {
root: process.cwd(),
mode: "development",
command: "dev",
metadata: new Map(),
warn() {},
});
}
test("authz plugin is inert until configured", async () => {
const instance = runner();
await instance.configure({});
const contributions = await instance.contributions();
expect(contributions.middleware).toEqual([]);
expect(contributions.migrations).toEqual([]);
});
test("database authz config contributes middleware and dialect migration", async () => {
const instance = runner();
const config: Record<string, unknown> = {
db: { driver: "postgres", url: "postgres://example" },
authz: {
store: "database",
middleware: true,
migrations: true,
defaultRoles: { signup: ["manager"] },
},
};
await instance.configure(config);
const contributions = await instance.contributions();
expect(contributions.middleware).toHaveLength(1);
expect(contributions.migrations).toHaveLength(1);
expect(contributions.migrations[0]?.source).toContain("SERIAL PRIMARY KEY");
expect(config.authz).toMatchObject({ strict: true });
});
test("database authz config refuses a missing database", async () => {
const instance = runner();
await expect(instance.configure({ authz: { store: "database" } })).rejects.toThrow(
"requires config.db",
);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.8.48",
"version": "0.8.49",
"type": "module",
"main": "src/index.ts",
"exports": {
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { runBuild } from "./build.ts";
import { runTypecheck } from "./types.ts";
async function runScript(root: string, name: string): Promise<void> {
const child = Bun.spawn(["bun", "run", name], {
cwd: root,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
const code = await child.exited;
if (code !== 0) throw new Error(`WRN-CHECK: '${name}' failed with exit code ${code}`);
}
/** Canonical generated-artifact-aware application verification pipeline. */
export async function runCheck(appRoot: string): Promise<void> {
const root = resolve(appRoot);
const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as {
scripts?: Record<string, string>;
};
await runBuild(root);
if (!(await runTypecheck(root))) throw new Error("WRN-CHECK: application typecheck failed");
for (const name of ["lint", "test", "format:check"] as const) {
if (manifest.scripts?.[name]) await runScript(root, name);
}
}
+6 -1
View File
@@ -93,7 +93,7 @@ Thumbs.db
"doctor": "wrnexus doctor .",
"analyze": "wrnexus analyze .",
"inspect": "wrnexus inspect packages .",
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
"check": "wrnexus check ."
},
"dependencies": {
"@wrnexus/ai": "${scaffoldFrameworkRange}",
@@ -227,8 +227,13 @@ export default tseslint.config(
dist/
.wrnexus/
**/.wrnexus/
.superpowers/
.claude/
*.log
CLAUDE.md
app/routes.gen.ts
app/types/*.generated.*
app/db/queries.gen.ts
`,
".editorconfig": `root = true
+3 -2
View File
@@ -14,7 +14,7 @@
* (`databases.<name>`), with files under `app/db/<name>/`.
*/
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
@@ -23,6 +23,7 @@ import {
type PackageMigrationDefinition,
} from "@wrnexus/plugin";
import { loadAppConfig, type AppConfig } from "@wrnexus/styles";
import { writeGeneratedFile } from "./write-generated.ts";
import {
generateQueriesFile,
analyzeMigrations,
@@ -156,7 +157,7 @@ export async function regenerateQueries(
.flatMap((f) => parseQueries(readFileSync(join(queriesDir, f), "utf8")));
const refs = await loadModelRefs(dbBase);
const code = generateQueriesFile(queries, refs, dialectOf(driver));
writeFileSync(join(dbBase, "queries.gen.ts"), code, "utf8");
writeGeneratedFile(join(dbBase, "queries.gen.ts"), code);
return queries.length;
}
+9
View File
@@ -60,6 +60,7 @@ Usage:
wrnexus generate types [app-dir] Generate application-wide route/component/key types
wrnexus routes [app-dir] Generate typed named routes
wrnexus typecheck [app-dir] Generate types and check TypeScript plus every .wrn file
wrnexus check [app-dir] Build/generate, typecheck, lint, test, and format-check
wrnexus mobile add <package...> Install Capacitor or Expo native packages
wrnexus mobile compile Compile .wrn pages into native Expo routes
wrnexus native list List cross-platform native capabilities
@@ -239,6 +240,14 @@ async function main(): Promise<void> {
else console.log("✓ Application types are valid");
break;
}
case "check": {
const appRoot = rest.find((arg) => !arg.startsWith("--")) ?? ".";
bootstrapProfile(appRoot, "production", rest);
const { runCheck } = await import("./check.ts");
await runCheck(appRoot);
console.log("✓ Application check passed");
break;
}
case "eject": {
const { runEject } = await import("./eject.ts");
const args = rest.filter((a) => !a.startsWith("--"));
+2 -2
View File
@@ -4,14 +4,14 @@
* startup; also exposed via `wrnexus generate routes`.
*/
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { buildRouter, generateRoutesFile } from "@wrnexus/router";
import { writeGeneratedFile } from "./write-generated.ts";
/** Regenerate `app/routes.gen.ts`. Returns the number of page routes. */
export function regenerateRoutes(appDir: string): number {
const router = buildRouter(appDir);
const code = generateRoutesFile(router.pages);
writeFileSync(join(appDir, "routes.gen.ts"), code, "utf8");
writeGeneratedFile(join(appDir, "routes.gen.ts"), code);
return router.pages.length;
}
+3 -2
View File
@@ -9,6 +9,7 @@ import { generate, generateTargets } from "@wrnexus/compiler";
import { regenerateRoutes } from "./routes.ts";
import { loadAppConfig } from "@wrnexus/styles";
import { createPluginRunner, discoverPlugins, type PluginContributions } from "@wrnexus/plugin";
import { writeGeneratedFile } from "./write-generated.ts";
function files(root: string, predicate: (path: string) => boolean): string[] {
if (!existsSync(root)) return [];
@@ -309,7 +310,7 @@ declare namespace WRNexusGenerated {
`;
mkdirSync(typeDir, { recursive: true });
const output = join(typeDir, "wrnexus.generated.d.ts");
writeFileSync(output, code, "utf8");
writeGeneratedFile(output, code);
// `.d.ts` contents are exempt from checking under `skipLibCheck: true` (set in the
// repo/app tsconfig), so the per-block assertions are written into a real `.ts` file
// instead — only genuine `.ts`/`.tsx` sources are compiled and checked.
@@ -321,7 +322,7 @@ declare namespace WRNexusGenerated {
${apiBlockAssertions(pageAsts, apiContracts, app)}
export {};
`;
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
writeGeneratedFile(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode);
writePluginArtifacts(root, pluginContributions);
return {
file: relative(root, output).replace(/\\/g, "/"),
+11
View File
@@ -0,0 +1,11 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
/** Write generated UTF-8 output only when bytes changed, preserving mtimes on no-op builds. */
export function writeGeneratedFile(path: string, content: string): boolean {
const normalized = content.replace(/\r\n/g, "\n");
if (existsSync(path) && readFileSync(path, "utf8").replace(/\r\n/g, "\n") === normalized) {
return false;
}
writeFileSync(path, normalized, "utf8");
return true;
}
+1 -3
View File
@@ -51,9 +51,7 @@ test("scaffoldApp includes production build and start scripts", () => {
expect(pkg.scripts.production).toBe("bun run build && bun run start");
expect(pkg.scripts.typecheck).toBe("tsc --noEmit");
expect(pkg.scripts.test).toBe("wrnexus test .");
expect(pkg.scripts.check).toBe(
"bun run typecheck && bun run lint && bun run test && bun run format:check",
);
expect(pkg.scripts.check).toBe("wrnexus check .");
} finally {
rmSync(parent, { recursive: true, force: true });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.8.11",
"version": "0.8.12",
"type": "module",
"main": "src/index.ts",
"exports": {
+41 -6
View File
@@ -22,7 +22,20 @@ import {
/** Translate a key for the active language, interpolating `{param}` placeholders. */
export type TFunction = (key: string, params?: Record<string, string | number>) => string;
export type Context = {
export interface PaginationOptions {
defaultLimit?: number;
maxLimit?: number;
}
export interface PaginationInput {
limit: number;
cursor?: string;
}
export interface Context<
Params extends Record<string, string> = Record<string, string>,
User = unknown,
> {
/** The raw incoming web-standard Request. */
req: Request;
/** Parsed URL of the request (pathname, query, etc.). */
@@ -32,17 +45,19 @@ export type Context = {
/** Translate a key for the active language (identity until the runtime sets it). */
t: TFunction;
/** Dynamic route params, e.g. `/users/[id]` -> `{ id: "42" }`. */
params: Record<string, string>;
params: Params;
/**
* Per-request scratch space. Middleware can attach values here
* (e.g. the authenticated user) and downstream handlers can read them.
*/
locals: Record<string, unknown>;
/** Resource resolved by a declarative route guard, when present. */
resource?: unknown;
/**
* The authenticated user for this request, or null when anonymous. Populated
* by the `sessionAuth` middleware (or `logIn`); read via `getUser(ctx)`.
*/
user?: unknown;
user?: User | null;
/** Active tenant/workspace resolved by tenant middleware. */
tenant?: Tenant;
/** Request tracer installed by observability middleware. */
@@ -59,7 +74,9 @@ export type Context = {
session: SessionStore;
/** Read-only localStorage snapshot sent by the browser for CSR data bindings. */
localStorage: LocalStorageSnapshot;
};
/** Parse and bound the standard `limit` and `cursor` query parameters. */
pagination(options?: PaginationOptions): PaginationInput;
}
/** Calls the next middleware in the chain (or the final route handler). */
export type Next = () => Promise<Response> | Response;
@@ -99,12 +116,15 @@ export type PageMeta = SeoConfig;
export type PageComponent = (ctx: Context) => string | Promise<string>;
/** Create a fresh context for an incoming request. */
export function createContext(req: Request, url: URL): Context {
export function createContext<Params extends Record<string, string> = Record<string, string>>(
req: Request,
url: URL,
): Context<Params> {
const cookies = createCookieStore(req);
return {
req,
url,
params: {},
params: {} as Params,
locals: {},
lang: "",
t: (key) => key,
@@ -113,9 +133,24 @@ export function createContext(req: Request, url: URL): Context {
// cookies get `Secure` behind a TLS-terminating proxy (matches CSRF cookies).
session: createSessionStore(cookies, req, undefined, url.protocol === "https:"),
localStorage: createLocalStorageSnapshot(req),
pagination(options = {}) {
const defaultLimit = positivePaginationInteger(options.defaultLimit, 25);
const maxLimit = positivePaginationInteger(options.maxLimit, 100);
const requested = Number(url.searchParams.get("limit"));
const limit =
Number.isInteger(requested) && requested > 0
? Math.min(requested, maxLimit)
: Math.min(defaultLimit, maxLimit);
const cursor = url.searchParams.get("cursor")?.trim() || undefined;
return { limit, cursor };
},
};
}
function positivePaginationInteger(value: number | undefined, fallback: number): number {
return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
}
/** Apply headers accumulated on the context, such as Set-Cookie. */
export function withContextHeaders(ctx: Context, res: Response): Response {
const headers = new Headers(res.headers);
+29
View File
@@ -33,6 +33,10 @@ export interface EndpointDefinition<I, O> {
input?: SchemaLike<I> | OutputSchemaLike<I>;
output?: SchemaLike<O> | OutputSchemaLike<O>;
auth?: "optional" | "required";
/** Permission checked through an installed @wrnexus/authz middleware. */
permission?: string;
/** Resource supplied to bound authorization policies. */
resource?: (ctx: Context, input: I) => unknown | Promise<unknown>;
description?: string;
tags?: string[];
handler(input: I, ctx: Context): O | Promise<O>;
@@ -111,6 +115,31 @@ export function defineEndpoint(
: await ctx.req.json().catch(() => ({}));
input = schemaValue(definition.input, resolvedInput);
}
if (definition.permission) {
const authz = (
ctx as Context & {
authz?: {
decide(
permission: string,
resource?: unknown,
): Promise<{ allowed: boolean; reason?: string }>;
};
}
).authz;
if (!authz) {
throw new EndpointError(
500,
"AUTHZ_NOT_CONFIGURED",
"Authorization middleware is not configured.",
);
}
const resource = definition.resource ? await definition.resource(ctx, input) : undefined;
const decision = await authz.decide(definition.permission, resource);
if (!decision.allowed) {
throw new EndpointError(403, "FORBIDDEN", "Permission denied.");
}
ctx.resource = resource;
}
const rawOutput = await definition.handler(input, ctx);
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
return output instanceof Response ? output : json({ data: output });
+3
View File
@@ -9,6 +9,8 @@ export type {
PageComponent,
SeoConfig,
TFunction,
PaginationOptions,
PaginationInput,
} from "./context.ts";
export { createContext, withContextHeaders } from "./context.ts";
export {
@@ -127,6 +129,7 @@ export {
export type {
CookieOptions,
CookieStore,
TransactionCookie,
LocalStorageSnapshot,
SessionStore,
SessionBackend,
+49 -1
View File
@@ -17,6 +17,16 @@ export interface CookieStore {
set(name: string, value: string, options?: CookieOptions): void;
delete(name: string, options?: CookieOptions): void;
headers(): string[];
transaction<T extends Record<string, unknown> = Record<string, unknown>>(
name: string,
options?: CookieOptions,
): TransactionCookie<T>;
}
export interface TransactionCookie<T extends Record<string, unknown>> {
set(value: T): void;
consume(): T | undefined;
clear(): void;
}
export interface SessionStore {
@@ -259,7 +269,7 @@ export function createCookieStore(req: Request): CookieStore {
const incoming = parseCookieHeader(req.headers.get("cookie") ?? "");
const outgoing: string[] = [];
return {
const store: CookieStore = {
get(name) {
return incoming[name];
},
@@ -287,7 +297,45 @@ export function createCookieStore(req: Request): CookieStore {
headers() {
return [...outgoing];
},
transaction<T extends Record<string, unknown>>(name: string, options: CookieOptions = {}) {
const policy: CookieOptions = {
path: "/",
httpOnly: true,
sameSite: "Lax",
maxAge: 600,
secure: new URL(req.url).protocol === "https:",
...options,
};
return {
set(value: T) {
const json = JSON.stringify(value);
const encoded = btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
store.set(name, encoded, policy);
},
consume(): T | undefined {
const encoded = store.get(name);
store.delete(name, policy);
if (!encoded) return undefined;
try {
const padded = encoded
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "=");
return JSON.parse(decodeURIComponent(escape(atob(padded)))) as T;
} catch {
return undefined;
}
},
clear() {
store.delete(name, policy);
},
};
},
};
return store;
}
export function createSessionStore(
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { createContext } from "../src/index.ts";
test("context pagination applies defaults, bounds and cursor parsing", () => {
const request = new Request("https://example.test/items?limit=500&cursor=next-1");
const ctx = createContext(request, new URL(request.url));
expect(ctx.pagination({ defaultLimit: 25, maxLimit: 100 })).toEqual({
limit: 100,
cursor: "next-1",
});
});
test("context pagination rejects invalid limits", () => {
const request = new Request("https://example.test/items?limit=-2");
const ctx = createContext(request, new URL(request.url));
expect(ctx.pagination()).toEqual({ limit: 25, cursor: undefined });
});
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test";
import { createContext, withContextHeaders } from "../src/index.ts";
test("transaction cookies preserve policy when consumed and delete exactly once", () => {
const firstRequest = new Request("https://example.test/oauth");
const first = createContext(firstRequest, new URL(firstRequest.url));
first.cookies.transaction<{ state: string }>("oauth").set({ state: "välue" });
const issued = withContextHeaders(first, new Response()).headers.get("set-cookie")!;
expect(issued).toContain("HttpOnly");
expect(issued).toContain("SameSite=Lax");
expect(issued).toContain("Secure");
const pair = issued.split(";", 1)[0]!;
const callbackRequest = new Request("https://example.test/callback", {
headers: { cookie: pair },
});
const callback = createContext(callbackRequest, new URL(callbackRequest.url));
expect(callback.cookies.transaction<{ state: string }>("oauth").consume()).toEqual({
state: "välue",
});
const consumed = withContextHeaders(callback, new Response()).headers.get("set-cookie")!;
expect(consumed).toContain("Max-Age=0");
expect(consumed).toContain("Path=/");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.8.17",
"version": "0.8.18",
"private": true,
"type": "module",
"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);
}
/** Execute a parameterized compare-and-set update and report whether this caller won. */
export async function compareAndSet(
db: Db,
options: {
table: string;
idColumn?: string;
id: string | number;
stateColumn?: string;
from: string;
to: string;
extra?: Record<string, unknown>;
},
): Promise<boolean> {
const table = identifier(options.table);
const idColumn = identifier(options.idColumn ?? "id");
const stateColumn = identifier(options.stateColumn ?? "status");
const entries = Object.entries(options.extra ?? {});
const assignments = [stateColumn, ...entries.map(([name]) => identifier(name))];
const dialect = db.driver.dialect;
const values = [options.to, ...entries.map(([, value]) => value), options.id, options.from];
const sql = `UPDATE ${table} SET ${assignments
.map((name, index) => `${name} = ${placeholder(dialect, index + 1)}`)
.join(
", ",
)} WHERE ${idColumn} = ${placeholder(dialect, assignments.length + 1)} AND ${stateColumn} = ${placeholder(dialect, assignments.length + 2)}`;
return (await db.exec(sql, values)).changes === 1;
}
/** Conservative default classifier for deadlock/serialization retry errors. */
export function isRetryableTransactionError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
+1
View File
@@ -54,6 +54,7 @@ export {
exists,
countRows,
withTransaction,
compareAndSet,
retryTransaction,
batch,
databaseHealth,
+12
View File
@@ -5,6 +5,7 @@ import {
createRepository,
databaseHealth,
retryTransaction,
compareAndSet,
} from "../src/index.ts";
import type { Driver, Row } from "../src/index.ts";
@@ -28,6 +29,17 @@ function memoryDriver(): Driver {
}
describe("database helper kit", () => {
test("compareAndSet reports whether the guarded transition won", async () => {
const db = createDb(memoryDriver());
expect(
await compareAndSet(db, {
table: "messages",
id: 1,
from: "pending",
to: "sending",
}),
).toBe(true);
});
test("provides repository CRUD helpers and health checks", async () => {
const db = createDb(memoryDriver());
const repository = createRepository<{ id: number; name: string }>(db, {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.8.44",
"version": "0.8.45",
"type": "module",
"main": "src/index.ts",
"exports": {
+76 -1
View File
@@ -1580,6 +1580,55 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
});
}
const authorization = (
mod.authorization as
| Record<
string,
| string
| {
permission: string;
resource?: (ctx: Context) => unknown | Promise<unknown>;
exposeReason?: boolean;
mayDiscover?: boolean | ((ctx: Context) => boolean | Promise<boolean>);
}
>
| undefined
)?.[method];
if (authorization) {
if (!ctx.user) {
return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const rule =
typeof authorization === "string" ? { permission: authorization } : authorization;
const resource = rule.resource ? await rule.resource(ctx) : undefined;
if (rule.resource && resource == null) {
const mayDiscover =
typeof rule.mayDiscover === "function"
? await rule.mayDiscover(ctx)
: Boolean(rule.mayDiscover);
return Response.json(
{ ok: false, error: mayDiscover ? "Not Found" : "Forbidden" },
{ status: mayDiscover ? 404 : 403 },
);
}
const decision = ctx.authz ? await ctx.authz.decide(rule.permission, resource) : undefined;
const permissions = ctx.locals.permissions;
const allowed = decision
? decision.allowed
: typeof permissions === "function"
? await permissions(rule.permission, ctx)
: Array.isArray(permissions) && permissions.includes(rule.permission);
if (!allowed) {
return Response.json(
rule.exposeReason
? { ok: false, error: "Forbidden", reason: decision?.reason }
: { ok: false, error: "Forbidden" },
{ status: 403 },
);
}
ctx.resource = resource;
}
return (await handler(ctx)) as Response;
}
@@ -1740,6 +1789,33 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
const storeContainer = requestStoreContainer(ctx.req, matched.route.raw);
const mod = await loadModule(matched.route.file);
ctx.params = matched.params;
const pageSecurity = (mod.__wrnexusSecurity ?? {}) as Record<string, string>;
if (/^(?:required|true)$/i.test(pageSecurity.auth ?? "") && !ctx.user) {
if (ctx.req.method.toUpperCase() === "POST") {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const login = new URL("/login", ctx.url);
login.searchParams.set("returnTo", ctx.url.pathname + ctx.url.search);
return Response.redirect(login, 302);
}
if (pageSecurity.permission) {
const decision = ctx.authz ? await ctx.authz.decide(pageSecurity.permission) : undefined;
const permissions = ctx.locals.permissions;
const allowed = decision
? decision.allowed
: typeof permissions === "function"
? await permissions(pageSecurity.permission, ctx)
: Array.isArray(permissions) && permissions.includes(pageSecurity.permission);
if (!allowed) {
if (ctx.req.method.toUpperCase() === "POST") {
return Response.json({ error: "Permission denied" }, { status: 403 });
}
const forbidden = new URL(pageSecurity.forbidden ?? "/forbidden", ctx.url);
if (decision?.reason) forbidden.searchParams.set("reason", decision.reason);
return Response.redirect(forbidden, 303);
}
}
if (ctx.req.method.toUpperCase() === "POST") {
const actionResponse = await handlePageAction(ctx, mod);
if (actionResponse) return actionResponse;
@@ -1749,7 +1825,6 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
throw new Error(`Page ${matched.route.file} has no default export`);
}
ctx.params = matched.params;
const meta = (mod.meta ?? {}) as PageMeta;
const pageNavigation = (mod.__wrnexusNavigation ?? {}) as { preserve?: string };
const pageCache = (mod.__wrnexusCache ?? {}) as Record<string, string>;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"main": "src/index.ts",
+25 -4
View File
@@ -14,6 +14,8 @@ export interface QueueStore {
remove(id: string): Promise<void>;
due(now: number, limit: number): Promise<Job[]>;
list(name?: string): Promise<Job[]>;
findByIdempotencyKey?(name: string, key: string): Promise<Job | null>;
size?(): Promise<number>;
claim?(id: string, worker: string, leaseUntil: number): Promise<boolean>;
}
@@ -48,6 +50,15 @@ export function memoryQueueStore(): QueueStore {
.filter((job) => !name || job.name === name)
.map((job) => structuredClone(job));
},
async findByIdempotencyKey(name, key) {
const job = [...jobs.values()].find(
(candidate) => candidate.name === name && candidate.idempotencyKey === key,
);
return job ? structuredClone(job) : null;
},
async size() {
return jobs.size;
},
};
}
@@ -73,6 +84,10 @@ export interface DurableQueue {
list(name?: string): Promise<Job[]>;
failed(): Job[];
retry(id: string): Promise<boolean>;
addBatch<T>(
name: string,
entries: readonly { data: T; options?: AddOptions }[],
): Promise<Job<T>[]>;
}
function positiveInteger(value: number, label: string): number {
@@ -178,12 +193,12 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
}
if (add.idempotencyKey) {
const existing = (await store.list(name)).find(
(job) => job.idempotencyKey === add.idempotencyKey,
);
const existing = store.findByIdempotencyKey
? await store.findByIdempotencyKey(name, add.idempotencyKey)
: (await store.list(name)).find((job) => job.idempotencyKey === add.idempotencyKey);
if (existing) return existing as Job<T>;
}
if ((await store.list()).length >= capacity) {
if ((store.size ? await store.size() : (await store.list()).length) >= capacity) {
throw new Error(`WRN-QUEUE-CAPACITY: queue capacity of ${capacity} reached`);
}
@@ -204,6 +219,12 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
return structuredClone(job);
},
async addBatch<T>(name: string, entries: readonly { data: T; options?: AddOptions }[]) {
const output: Job<T>[] = [];
for (const entry of entries) output.push(await this.add(name, entry.data, entry.options));
return output;
},
process(name, handler) {
if (!name.trim()) throw new TypeError("queue worker name cannot be empty");
handlers.set(name, handler as JobHandler);
+3
View File
@@ -92,6 +92,9 @@ export function defineJob<I>(definition: JobDefinition<I>): JobDefinition<I> {
return definition;
}
export { defineWorker, runWorker } from "./worker.ts";
export type { WorkerDefinition } from "./worker.ts";
export interface WorkflowStep<I, O> {
name: string;
run(input: I): O | Promise<O>;
+25
View File
@@ -54,6 +54,18 @@ export function redisQueueStore(client: RedisQueueClient, prefix = "wrnexus:queu
.map((value) => JSON.parse(value) as Job)
.filter((job) => !name || job.name === name);
},
async findByIdempotencyKey(name, idempotencyKey) {
const ids = await client.smembers(jobs);
const values = await Promise.all(ids.map((id) => client.get(key(id))));
const found = values
.filter((value): value is string => value !== null)
.map((value) => JSON.parse(value) as Job)
.find((job) => job.name === name && job.idempotencyKey === idempotencyKey);
return found ?? null;
},
async size() {
return (await client.smembers(jobs)).length;
},
async claim(id, worker, leaseUntil) {
const ttl = Math.max(1, leaseUntil - Date.now());
return Boolean(await client.set(`${prefix}:lease:${id}`, worker, { NX: true, PX: ttl }));
@@ -98,6 +110,19 @@ export function postgresQueueStore(db: SqlQueueClient, table = "wrnexus_jobs"):
);
return result.rows.map((row) => row.payload);
},
async findByIdempotencyKey(name, key) {
const result = await db.query<{ payload: Job }>(
`SELECT payload FROM ${table} WHERE name=$1 AND payload->>'idempotencyKey'=$2 LIMIT 1`,
[name, key],
);
return result.rows[0]?.payload ?? null;
},
async size() {
const result = await db.query<{ total: number | string }>(
`SELECT COUNT(*) AS total FROM ${table}`,
);
return Number(result.rows[0]?.total ?? 0);
},
async claim(id, worker, leaseUntil) {
const result = await db.query(
`UPDATE ${table} SET lease_owner=$2,lease_until=$3 WHERE id=$1 AND (lease_until IS NULL OR lease_until < $4) RETURNING id`,
+36
View File
@@ -0,0 +1,36 @@
import type { JobHandler } from "./index.ts";
import type { DurableQueue } from "./durable.ts";
export interface WorkerDefinition<T> {
name: string;
handler: JobHandler<T>;
onStart?: () => void | Promise<void>;
onStop?: () => void | Promise<void>;
}
export function defineWorker<T>(definition: WorkerDefinition<T>): WorkerDefinition<T> {
if (!definition.name.trim()) throw new TypeError("worker name cannot be empty");
return Object.freeze({ ...definition });
}
/** Explicit process lifecycle for queue workers; safe to start and stop repeatedly. */
export function runWorker<T>(queue: DurableQueue, definition: WorkerDefinition<T>) {
let started = false;
return {
async start() {
if (started) return;
started = true;
queue.process(definition.name, definition.handler);
await definition.onStart?.();
},
async stop(options?: { force?: boolean }) {
if (!started) return;
started = false;
await queue.shutdown(options);
await definition.onStop?.();
},
get running() {
return started;
},
};
}
+19
View File
@@ -5,8 +5,27 @@ import {
cronToInterval,
defineWorkflow,
memoryQueueStore,
defineWorker,
runWorker,
} from "../src/index.ts";
test("durable queues batch enqueue and worker lifecycle are idempotent", async () => {
const queue = createDurableQueue();
const seen: number[] = [];
const worker = runWorker(
queue,
defineWorker<number>({ name: "number", handler: (job) => void seen.push(job.data) }),
);
await worker.start();
await worker.start();
const jobs = await queue.addBatch("number", [{ data: 1 }, { data: 2 }]);
expect(jobs).toHaveLength(2);
await queue.drain();
expect(seen).toEqual([1, 2]);
await worker.stop();
expect(worker.running).toBe(false);
});
test("processes a job", async () => {
const queue = createQueue();
const done: string[] = [];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.8.16",
"version": "0.8.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+16 -3
View File
@@ -330,6 +330,19 @@ export interface AppConfig {
profiles?: Record<string, Partial<Omit<AppConfig, "profiles">>>;
}
/** User-authored configuration; retained as an explicit name for API clarity. */
export type AppConfigInput = AppConfig;
/** Configuration after profile/layer resolution. Required input fields stay required. */
export type ResolvedAppConfig<T extends AppConfig = AppConfig> = Omit<
AppConfig,
"extends" | "profiles"
> &
Omit<T, "extends" | "profiles"> & {
readonly extends?: undefined;
readonly profiles?: undefined;
};
const CONFIG_NAMES = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
/**
@@ -474,7 +487,7 @@ export async function loadRawConfig(appRoot: string): Promise<AppConfig> {
}
/** Load `wrnexus.config.*`, applying the active profile's overrides. */
export async function loadAppConfig(appRoot: string, profile?: string): Promise<AppConfig> {
export async function loadAppConfig(appRoot: string, profile?: string): Promise<ResolvedAppConfig> {
const active = profile ?? resolveProfile();
// Configuration modules commonly read DATABASE_URL and other settings during
// module evaluation. Load the profile cascade before importing the module so
@@ -494,7 +507,7 @@ export async function loadAppConfig(appRoot: string, profile?: string): Promise<
`Invalid wrnexus.config: ${errors.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`,
);
}
return merged;
return merged as ResolvedAppConfig;
}
/**
@@ -573,7 +586,7 @@ export interface ConfigIssue {
message: string;
}
export function defineConfig(config: AppConfig): AppConfig {
export function defineConfig<const T extends AppConfig>(config: T): T {
return config;
}
+2
View File
@@ -9,6 +9,8 @@
export type {
AppConfig,
AppConfigInput,
ResolvedAppConfig,
BuildConfig,
ConfigIssue,
DevToolbarConfig,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/syntax",
"version": "0.8.12",
"version": "0.8.13",
"type": "module",
"main": "src/index.ts",
"exports": {
+18
View File
@@ -393,6 +393,24 @@ export function parse(source: string): PageAst {
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
}
switch (kw.value) {
case "auth": {
lx.next();
expect("eq");
const value = lx.next();
if (value.type !== "ident" || !["true", "false"].includes(value.value)) {
throw new ParseError(`Expected auth = true or false at offset ${value.pos}`);
}
security.auth = value.value === "true" ? "required" : "false";
break;
}
case "permission": {
lx.next();
expect("eq");
const value = expect("string").value.trim();
if (!value) throw new ParseError(`Page permission cannot be empty at offset ${kw.pos}`);
security.permission = value;
break;
}
case "layout": {
// layout = "public" — selects app/layouts/<name>.wrn for this page.
lx.next();
+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",
"version": "0.8.9",
"version": "0.8.10",
"private": true,
"type": "module",
"main": "src/index.ts",
+47 -1
View File
@@ -3,6 +3,9 @@ import { createContext, type Context, type ProblemDetails } from "@wrnexus/core"
export interface TestRequestOptions extends Omit<RequestInit, "body"> {
body?: BodyInit | Record<string, unknown> | URLSearchParams | FormData | null;
baseUrl?: string;
params?: Record<string, string>;
user?: unknown;
locals?: Record<string, unknown>;
}
/** Build a web-standard Request with convenient JSON/FormData handling. */
@@ -31,7 +34,50 @@ export function testRequest(path = "/", options: TestRequestOptions = {}): Reque
/** Create a complete Context suitable for middleware and route unit tests. */
export function testContext(path = "/", options: TestRequestOptions = {}): Context {
const request = testRequest(path, options);
return createContext(request, new URL(request.url));
const ctx = createContext(request, new URL(request.url));
ctx.params = { ...options.params };
ctx.user = options.user;
ctx.locals = { ...options.locals };
return ctx;
}
/** Preferred descriptive alias for testContext(). */
export const createTestContext = testContext;
/** Fluent, Bun-compatible fetch mock (including Bun.fetch.preconnect). */
export type FetchMock = typeof fetch & {
readonly calls: ReadonlyArray<{ input: string | URL | Request; init?: RequestInit }>;
respondOnce(response: Response | (() => Response | Promise<Response>)): FetchMock;
reset(): void;
};
export function createFetchMock(): FetchMock {
const responses: Array<Response | (() => Response | Promise<Response>)> = [];
const calls: Array<{ input: string | URL | Request; init?: RequestInit }> = [];
const implementation = async (input: string | URL | Request, init?: RequestInit) => {
calls.push({ input, init });
const next = responses.shift();
if (!next) throw new Error("WRN-TEST-FETCH: no response was queued for this request");
return typeof next === "function" ? next() : next.clone();
};
const mock = implementation as FetchMock;
Object.defineProperties(mock, {
calls: { get: () => calls },
respondOnce: {
value(response: Response | (() => Response | Promise<Response>)) {
responses.push(response);
return mock;
},
},
reset: {
value() {
responses.length = 0;
calls.length = 0;
},
},
preconnect: { value: () => undefined },
});
return mock;
}
export interface JsonResponse<T> {
+12 -1
View File
@@ -181,15 +181,26 @@ export async function createHarness(
};
}
/** Preferred full-stack name; retained alongside createHarness for compatibility. */
export const createTestApp = createHarness;
export {
testRequest,
testContext,
createTestContext,
readJsonResponse,
expectProblem,
deferred,
waitFor,
MemoryCookieJar,
createFetchMock,
} from "./advanced.ts";
export type {
TestRequestOptions,
JsonResponse,
Deferred,
WaitForOptions,
FetchMock,
} from "./advanced.ts";
export type { TestRequestOptions, JsonResponse, Deferred, WaitForOptions } from "./advanced.ts";
export { withDatabaseRollback, createFactory, captureBrowserArtifacts } from "./platform.ts";
export type { TransactionalDatabase, BrowserArtifactPage } from "./platform.ts";
+28 -1
View File
@@ -1,4 +1,12 @@
import { test, expect, renderComponent, mountHtml, callRoute } from "../src/index.ts";
import {
test,
expect,
renderComponent,
mountHtml,
callRoute,
createFetchMock,
createTestContext,
} from "../src/index.ts";
const COUNTER = `component Counter {
props {
@@ -30,3 +38,22 @@ test("callRoute invokes an API handler with a Context", async () => {
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, path: "/api/ping", method: "POST" });
});
test("createTestContext installs route params, locals and a user without casts", () => {
const user = { id: "u1" };
const ctx = createTestContext("/items/42", {
params: { id: "42" },
user,
locals: { trace: "test" },
});
expect(ctx.params.id).toBe("42");
expect(ctx.user).toBe(user);
expect(ctx.locals.trace).toBe("test");
});
test("createFetchMock is fluent and compatible with Bun fetch", async () => {
const fetchMock: typeof fetch = createFetchMock().respondOnce(Response.json({ ok: true }));
const response = await fetchMock("https://example.test/token");
expect(await response.json()).toEqual({ ok: true });
expect(typeof fetchMock.preconnect).toBe("function");
});