feat: centralize application framework primitives
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.48",
|
||||
"version": "0.8.49",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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("--"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, "/"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.8.11",
|
||||
"version": "0.8.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.8.17",
|
||||
"version": "0.8.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -54,6 +54,7 @@ export {
|
||||
exists,
|
||||
countRows,
|
||||
withTransaction,
|
||||
compareAndSet,
|
||||
retryTransaction,
|
||||
batch,
|
||||
databaseHealth,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.44",
|
||||
"version": "0.8.45",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.8.16",
|
||||
"version": "0.8.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
export type {
|
||||
AppConfig,
|
||||
AppConfigInput,
|
||||
ResolvedAppConfig,
|
||||
BuildConfig,
|
||||
ConfigIssue,
|
||||
DevToolbarConfig,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.8.12",
|
||||
"version": "0.8.13",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user