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