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

Dashboard

} + }`); + expect(ast.security).toEqual({ auth: "required", permission: "dashboard:read" }); +}); diff --git a/packages/test/package.json b/packages/test/package.json index 7cfb64a3..9233a06c 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.8.9", + "version": "0.8.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/test/src/advanced.ts b/packages/test/src/advanced.ts index f3c59c10..614bf4a2 100644 --- a/packages/test/src/advanced.ts +++ b/packages/test/src/advanced.ts @@ -3,6 +3,9 @@ import { createContext, type Context, type ProblemDetails } from "@wrnexus/core" export interface TestRequestOptions extends Omit { body?: BodyInit | Record | URLSearchParams | FormData | null; baseUrl?: string; + params?: Record; + user?: unknown; + locals?: Record; } /** Build a web-standard Request with convenient JSON/FormData handling. */ @@ -31,7 +34,50 @@ export function testRequest(path = "/", options: TestRequestOptions = {}): Reque /** Create a complete Context suitable for middleware and route unit tests. */ export function testContext(path = "/", options: TestRequestOptions = {}): Context { const request = testRequest(path, options); - return createContext(request, new URL(request.url)); + const ctx = createContext(request, new URL(request.url)); + ctx.params = { ...options.params }; + ctx.user = options.user; + ctx.locals = { ...options.locals }; + return ctx; +} + +/** Preferred descriptive alias for testContext(). */ +export const createTestContext = testContext; + +/** Fluent, Bun-compatible fetch mock (including Bun.fetch.preconnect). */ +export type FetchMock = typeof fetch & { + readonly calls: ReadonlyArray<{ input: string | URL | Request; init?: RequestInit }>; + respondOnce(response: Response | (() => Response | Promise)): FetchMock; + reset(): void; +}; + +export function createFetchMock(): FetchMock { + const responses: Array Response | Promise)> = []; + 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)) { + responses.push(response); + return mock; + }, + }, + reset: { + value() { + responses.length = 0; + calls.length = 0; + }, + }, + preconnect: { value: () => undefined }, + }); + return mock; } export interface JsonResponse { diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts index 3609143e..407bb754 100644 --- a/packages/test/src/index.ts +++ b/packages/test/src/index.ts @@ -181,15 +181,26 @@ export async function createHarness( }; } +/** Preferred full-stack name; retained alongside createHarness for compatibility. */ +export const createTestApp = createHarness; + export { testRequest, testContext, + createTestContext, readJsonResponse, expectProblem, deferred, waitFor, MemoryCookieJar, + createFetchMock, +} from "./advanced.ts"; +export type { + TestRequestOptions, + JsonResponse, + Deferred, + WaitForOptions, + FetchMock, } from "./advanced.ts"; -export type { TestRequestOptions, JsonResponse, Deferred, WaitForOptions } from "./advanced.ts"; export { withDatabaseRollback, createFactory, captureBrowserArtifacts } from "./platform.ts"; export type { TransactionalDatabase, BrowserArtifactPage } from "./platform.ts"; diff --git a/packages/test/test/test-utils.test.ts b/packages/test/test/test-utils.test.ts index ab0b5bfe..5c8716be 100644 --- a/packages/test/test/test-utils.test.ts +++ b/packages/test/test/test-utils.test.ts @@ -1,4 +1,12 @@ -import { test, expect, renderComponent, mountHtml, callRoute } from "../src/index.ts"; +import { + test, + expect, + renderComponent, + mountHtml, + callRoute, + createFetchMock, + createTestContext, +} from "../src/index.ts"; const COUNTER = `component Counter { props { @@ -30,3 +38,22 @@ test("callRoute invokes an API handler with a Context", async () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, path: "/api/ping", method: "POST" }); }); + +test("createTestContext installs route params, locals and a user without casts", () => { + const user = { id: "u1" }; + const ctx = createTestContext("/items/42", { + params: { id: "42" }, + user, + locals: { trace: "test" }, + }); + expect(ctx.params.id).toBe("42"); + expect(ctx.user).toBe(user); + expect(ctx.locals.trace).toBe("test"); +}); + +test("createFetchMock is fluent and compatible with Bun fetch", async () => { + const fetchMock: typeof fetch = createFetchMock().respondOnce(Response.json({ ok: true })); + const response = await fetchMock("https://example.test/token"); + expect(await response.json()).toEqual({ ok: true }); + expect(typeof fetchMock.preconnect).toBe("function"); +});