diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 2bd55810..8b1845f2 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -472,7 +472,6 @@ export async function startServer(opts: ServeOptions): Promise { security: opts.security, observability: opts.observability, tenancy: opts.tenancy, - authz: authzCatalog, navigation: opts.navigation, clientRuntimes: pluginContributions.clientRuntimes, hub, @@ -636,7 +635,6 @@ export async function startServer(opts: ServeOptions): Promise { (file) => loadModule(file) as Promise<{ default?: AuthzModule }>, ); setAuthzCatalog(nextAuthzCatalog); - runtimeDeps.authz = nextAuthzCatalog; } catch (error) { console.error( "[wrnexus] authz hot update failed — the PREVIOUS catalog remains authoritative " + diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 9099b513..f6be505d 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -38,7 +38,7 @@ import { VALIDATE_RUNTIME } from "@wrnexus/validation"; import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n"; import { setDb, registerLazyDb, getDb, hasDb, migrate } from "@wrnexus/db"; import { connectFromConfig } from "@wrnexus/db/connect"; -import { mergeCatalogs, setAuthzCatalog, type AuthzModule } from "@wrnexus/authz"; +import { hasAuthzCatalog, mergeCatalogs, setAuthzCatalog, type AuthzModule } from "@wrnexus/authz"; import { configureStorage, serveStoredFile, @@ -416,17 +416,25 @@ export function createProductionHandlers( // function was ever called, specifically so a middleware module that reads // getAuthzCatalog() at its own module scope sees a populated catalog — this // function's body runs too late for that (it is reached only once every - // OTHER static import, including middleware, has already evaluated). This - // pass still runs unconditionally (not skipped when the catalog is already - // set) so a direct caller that bypasses the generated entry — and thus - // never ran that early pass — still gets a correctly merged, validated - // catalog, and so this function's own authorization handling stays fully - // testable in isolation. + // OTHER static import, including middleware, has already evaluated). + // + // The merge+validation of opts.authz always runs (a genuine conflict must + // still fail the boot loudly, no matter which pass discovers it). But + // setAuthzCatalog is only called when this pass actually has something to + // contribute, OR when nothing has been set yet: client.ts documents an + // escape hatch where a direct caller of createProductionHandlers may call + // setAuthzCatalog(catalog) itself before importing anything that reads it, + // specifically for a custom entry that never ran the generated + // `.authz-setup.ts` pass. Calling setAuthzCatalog unconditionally here would + // clobber that caller's catalog with an empty one whenever opts.authz is + // omitted — silently deleting every permission the app declared. for (const missing of (opts.authz ?? []).filter((entry) => !entry.module)) { console.warn(`[wrnexus] authz declaration ${missing.source} has no default export; skipping.`); } - const authzCatalog = mergeCatalogs(resolveAuthzSources(opts.authz ?? [])); - setAuthzCatalog(authzCatalog); + const mergedAuthzCatalog = mergeCatalogs(resolveAuthzSources(opts.authz ?? [])); + if ((opts.authz?.length ?? 0) > 0 || !hasAuthzCatalog()) { + setAuthzCatalog(mergedAuthzCatalog); + } // Middleware is already an ordered array of functions. const getMiddleware = async (): Promise => manifest.middleware; @@ -463,7 +471,6 @@ export function createProductionHandlers( security: opts.security, observability: opts.observability, tenancy: opts.tenancy, - authz: authzCatalog, navigation: opts.navigation, maxBodyBytes: opts.maxBodyBytes, realtimeBus: realtimeBusFromConfig(opts.realtime), diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index f572b354..e8534bc2 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -63,7 +63,6 @@ import { requestStoreContainer, } from "@wrnexus/ssr/store-context"; import type { StoreDefinition } from "@wrnexus/store"; -import type { AuthzCatalog } from "@wrnexus/authz"; import type { ClientRuntimeDefinition } from "@wrnexus/plugin"; import { CacheCoordinator } from "@wrnexus/cache"; import { generateServiceWorker } from "@wrnexus/pwa"; @@ -186,17 +185,6 @@ export interface RuntimeDeps { health?: HealthRegistry; /** Built-in tenant identity resolution. */ tenancy?: TenancyConfig; - /** - * Process-wide authorization catalog, merged from `app/authz/*.ts` at boot - * (dev: `loadAppAuthzCatalog`; prod: `mergeCatalogs` over the build's static - * imports). Also reachable via `@wrnexus/authz`'s `getAuthzCatalog()` - * singleton, which is what the app's own `authzMiddleware` registration - * actually reads — this field exists so the request pipeline can see the - * catalog without importing that singleton directly. The framework never - * installs `authzMiddleware` itself; the app always registers it with its - * own store. - */ - authz?: AuthzCatalog; /** Max request body size in bytes (413 above this). Default 10 MB. */ maxBodyBytes?: number; /** HMR hub for browser live-update sockets (dev only). */ diff --git a/packages/dev-server/test/authz-prod.test.ts b/packages/dev-server/test/authz-prod.test.ts index 71a753f1..910ac49a 100644 --- a/packages/dev-server/test/authz-prod.test.ts +++ b/packages/dev-server/test/authz-prod.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { defineAuthz, getAuthzCatalog } from "@wrnexus/authz"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; +import { defineAuthz, getAuthzCatalog, mergeCatalogs, setAuthzCatalog } from "@wrnexus/authz"; import { applyAuthzManifestEarly, createProductionHandlers, @@ -15,13 +17,42 @@ const EMPTY_MANIFEST: ProdManifest = { layouts: [], }; -describe("createProductionHandlers authorization wiring (the idempotent second pass)", () => { - test("an empty (or absent) authz array yields an empty catalog, no error", () => { - createProductionHandlers(EMPTY_MANIFEST, { authz: [] }); - expect(getAuthzCatalog().permissions.size).toBe(0); +const PROD_URL = pathToFileURL(join(import.meta.dir, "..", "src", "prod.ts")).href; +describe("createProductionHandlers authorization wiring (the idempotent second pass)", () => { + test("an empty/absent authz array never throws, whatever the ambient catalog state", () => { + createProductionHandlers(EMPTY_MANIFEST, { authz: [] }); createProductionHandlers(EMPTY_MANIFEST, {}); - expect(getAuthzCatalog().permissions.size).toBe(0); + }); + + test("starting from a genuinely unset catalog, an empty/absent authz array yields an empty catalog", async () => { + // bun test does NOT isolate module instances between test files run in + // the same invocation (see client.test.ts's comment on the same trap), + // so "no catalog set yet" cannot be observed reliably in-process — some + // other file's test may already have called setAuthzCatalog. A fresh + // subprocess is the only way to guarantee that. + const proc = Bun.spawn({ + cmd: [ + "bun", + "-e", + `const mod = await import(${JSON.stringify(PROD_URL)}); + const manifest = { pages: [], api: [], realtime: [], middleware: [], components: [], layouts: [] }; + mod.createProductionHandlers(manifest, { authz: [] }); + const { getAuthzCatalog } = await import("@wrnexus/authz"); + console.log("SIZE:" + getAuthzCatalog().permissions.size);`, + ], + stdout: "pipe", + stderr: "pipe", + cwd: join(import.meta.dir, ".."), + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain("SIZE:0"); }); test("a declaration with no default export warns and is skipped, not fatal", () => { @@ -107,6 +138,64 @@ describe("createProductionHandlers authorization wiring (the idempotent second p expect(getAuthzCatalog().permissions.has("a:read")).toBe(false); expect(getAuthzCatalog().permissions.has("b:read")).toBe(true); }); + + test("a caller-set catalog survives when opts.authz is omitted (the client.ts escape hatch)", () => { + // client.ts documents that a direct caller of createProductionHandlers may + // call setAuthzCatalog(catalog) itself, before importing anything that + // reads it, when it bypasses the generated `.authz-setup.ts` entry. That + // catalog must not be wiped just because this call's own opts.authz is + // empty/absent. + const preset = mergeCatalogs([ + { + source: "preset.ts", + module: defineAuthz({ + permissions: { "preset:read": {}, "preset:write": {}, "preset:delete": {} }, + }), + }, + ]); + setAuthzCatalog(preset); + expect(getAuthzCatalog().permissions.size).toBe(3); + + createProductionHandlers(EMPTY_MANIFEST, {}); + + expect(getAuthzCatalog()).toBe(preset); + expect(getAuthzCatalog().permissions.size).toBe(3); + expect(getAuthzCatalog().permissions.has("preset:read")).toBe(true); + }); + + test("a non-empty opts.authz still sets (and still throws on a conflict), even over a pre-set catalog", () => { + const preset = mergeCatalogs([ + { source: "preset.ts", module: defineAuthz({ permissions: { "preset:read": {} } }) }, + ]); + setAuthzCatalog(preset); + + // A non-empty authz array must still replace the pre-set catalog with the + // merged result of ITS OWN declarations, not defer to the pre-set one. + createProductionHandlers(EMPTY_MANIFEST, { + authz: [{ source: "own.ts", module: defineAuthz({ permissions: { "own:read": {} } }) }], + }); + expect(getAuthzCatalog()).not.toBe(preset); + expect(getAuthzCatalog().permissions.has("own:read")).toBe(true); + expect(getAuthzCatalog().permissions.has("preset:read")).toBe(false); + + // And a genuine conflict inside that non-empty array still throws, exactly + // as it did before this pass became conditional. + setAuthzCatalog(preset); + expect(() => + createProductionHandlers(EMPTY_MANIFEST, { + authz: [ + { + source: "a.ts", + module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }), + }, + { + source: "b.ts", + module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }), + }, + ], + }), + ).toThrow(/WRN-AUTHZ-CONFLICT/); + }); }); describe("applyAuthzManifestEarly (the eager, silent pass called only by the generated .authz-setup.ts)", () => {