diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index 83d15413..67398c01 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -1423,6 +1423,7 @@ "@wrnexus/dev-server": { ".": [ "AssetServer", + "AuthzManifestEntry", "FetchHandler", "GatewayApp", "GatewayAuth", @@ -1435,6 +1436,7 @@ "ServeOptions", "WrnCompileMetrics", "WsData", + "applyAuthzManifestEarly", "createHandlers", "createProductionHandlers", "createProductionServer", diff --git a/packages/authz/src/client.ts b/packages/authz/src/client.ts index dba562d2..a1504918 100644 --- a/packages/authz/src/client.ts +++ b/packages/authz/src/client.ts @@ -2,15 +2,32 @@ * A process-wide authorization catalog registry, mirroring `@wrnexus/db`'s * `client.ts` (`setDb`/`getDb`/`hasDb`). It exists for the same reason: app * middleware runs at module-eval time — `app/middleware/*.ts` registers - * `authzMiddleware({ catalog, store, ... })` itself, and it needs the merged - * catalog *then*, before the first request. Passing it through `ctx` does not - * work at that point, so the framework loads and merges every `app/authz/*.ts` - * declaration at boot (dev: `loadAppAuthzCatalog` + `setAuthzCatalog`, before - * middleware is resolved; prod: `mergeCatalogs` over the statically-imported - * declarations + `setAuthzCatalog`, before the server starts listening) and - * stashes it here. The framework never installs `authzMiddleware` itself — the - * app always chooses its own store and registers the middleware; this registry - * only makes the merged catalog reachable when it does. + * `authzMiddleware({ catalog, store, ... })` itself, an EAGER call (the same + * shape as `logger.ts`'s `export default requestLogger({...})`), and it needs + * the merged catalog *then*, before its own module body finishes running. + * Passing it through `ctx` does not work at that point, so the framework + * loads and merges every `app/authz/*.ts` declaration and stashes it here + * before any other module can observe it: + * + * - dev: `startServer` calls `loadAppAuthzCatalog` + `setAuthzCatalog` + * before middleware is resolved. + * - prod (the normal `wrnexus build` output): the generated entry statically + * imports a small `.authz-setup.ts` module FIRST — before any page, API, + * or middleware import — which calls `setAuthzCatalog` at ITS OWN module + * scope. ES modules evaluate every static import before the importing + * module's body runs, and evaluate sibling imports in declaration order, + * so import position is evaluation order: this guarantees the catalog + * exists before app middleware's own module body (which may read it + * eagerly) ever evaluates. `createProductionHandlers` (`prod.ts`) then + * repeats the merge as an idempotent second pass, mainly so a caller who + * bypasses the generated entry and invokes it directly still gets a + * catalog — for THAT path specifically, an eager module-scope read in + * middleware is only safe if the caller sets the catalog before importing + * the middleware itself, since no generated `.authz-setup.ts` runs first. + * + * The framework never installs `authzMiddleware` itself — the app always + * chooses its own store and registers the middleware; this registry only + * makes the merged catalog reachable when it does. */ import type { AuthzCatalog } from "./types.ts"; @@ -28,11 +45,13 @@ export function getAuthzCatalog(): AuthzCatalog { if (!catalog) { throw new Error( "WRN-AUTHZ-SETUP: no authorization catalog is configured. The dev server and " + - "production build call loadAppAuthzCatalog()/mergeCatalogs() and setAuthzCatalog() " + - "automatically before your app's middleware runs. If you're seeing this, either " + - "getAuthzCatalog() ran before that boot step (e.g. at import time) or you're " + - "outside the normal boot path (a standalone script or test) and must call " + - "setAuthzCatalog(catalog) yourself first.", + "`wrnexus build`'s generated production entry both call setAuthzCatalog() before " + + "any other module — including your app's middleware — evaluates. If you're seeing " + + "this: (a) you're on a custom production entry that calls createProductionHandlers " + + "directly instead of the generated one, so you must call setAuthzCatalog(catalog) " + + "yourself before importing anything that reads it eagerly; or (b) you're outside " + + "the normal boot path entirely (a standalone script or test) and must call " + + "setAuthzCatalog(catalog) first.", ); } return catalog; diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index dde41c08..7f72ae3d 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -563,6 +563,46 @@ export async function runBuild(appRoot: string): Promise { const imports: string[] = []; let counter = 0; + // Authorization: emit a small side-effecting module that statically imports + // every app/authz/*.ts declaration and calls setAuthzCatalog EAGERLY, then + // import THAT MODULE FIRST — before pages/api/realtime/middleware/ + // components/layouts — so it runs before any other static import's module + // body, including app middleware that reads getAuthzCatalog() at module + // scope (the same eager shape authzMiddleware({ catalog, ... }) itself + // requires; app/middleware/logger.ts's `export default requestLogger({...})` + // is the same pattern). ES modules evaluate every static import before the + // importing module's own body runs, and evaluate sibling imports in + // declaration order — so import POSITION is evaluation order, and this + // must be imports[0], strictly before every other push into `imports` + // below (in particular before any `mw*` import). This module is + // deliberately silent about a missing default export (see + // applyAuthzManifestEarly in @wrnexus/dev-server): createProductionHandlers + // performs the identical merge again, with its warnings, as an idempotent + // second pass — both for adapters that bypass this generated entry and to + // avoid warning twice about the same declaration in the normal path. + { + let authzSetupCounter = 0; + const authzSetupImports: string[] = []; + const authzSetupEntries = router.authz + .map((a) => { + const v = `d${authzSetupCounter++}`; + authzSetupImports.push(`import * as ${v} from ${JSON.stringify(fwd(a.file))};`); + return `{ source: ${JSON.stringify(fwd(a.file))}, module: ${v}.default }`; + }) + .join(", "); + const authzSetupContent = `// AUTO-GENERATED authz catalog setup — do not edit. +// Imported FIRST by the production entry (see the "Authorization" comment +// there) so getAuthzCatalog() is populated before any other static import's +// module body runs. +import { applyAuthzManifestEarly } from "@wrnexus/dev-server"; +${authzSetupImports.join("\n")} + +applyAuthzManifestEarly([${authzSetupEntries}]); +`; + writeFileSync(join(distDir, ".authz-setup.ts"), authzSetupContent, "utf8"); + imports.push(`import "./.authz-setup.ts";`); + } + const manifestRoutes = (routes: Route[]): string => { const parts = routes.map((r) => { const v = `m${counter++}`; @@ -604,15 +644,18 @@ export async function runBuild(appRoot: string): Promise { .join(", "); if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`); - // Authorization declarations (app/authz/*.ts), statically imported like - // components/layouts — NOT baked into JSON like schemasJs, because the - // catalog contains policy FUNCTIONS, which JSON.stringify cannot carry. - // Each module is passed through by reference in ProdOptions.authz and - // merged into the process-wide catalog at prod startup (prod.ts), before - // the server begins listening, so a conflicting pair of declarations fails - // the boot instead of surfacing on the first request. A file with no - // default export becomes `module: undefined` here; prod.ts warns and skips - // it, matching the dev loader (authz-boot.ts). + // Authorization declarations again, this time for ProdOptions.authz — a + // SEPARATE set of static imports of the exact same files (harmless; ES + // modules are evaluated once and shared across every importer), statically + // imported like components/layouts — NOT baked into JSON like schemasJs, + // because the catalog contains policy FUNCTIONS, which JSON.stringify + // cannot carry. Each module is passed through by reference and merged + // AGAIN into the process-wide catalog by createProductionHandlers's second + // pass (prod.ts) — see the ".authz-setup.ts" block above for the EARLY, + // eager pass that actually makes the catalog visible to app middleware. A + // file with no default export becomes `module: undefined` here; + // createProductionHandlers warns and skips it, matching the dev loader + // (authz-boot.ts). const authzLit = router.authz .map((a) => { const v = `az${counter++}`; diff --git a/packages/cli/test/authz-prod-coldstart.test.ts b/packages/cli/test/authz-prod-coldstart.test.ts new file mode 100644 index 00000000..14571c05 --- /dev/null +++ b/packages/cli/test/authz-prod-coldstart.test.ts @@ -0,0 +1,128 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runBuild } from "../src/build.ts"; + +// This is the regression test for a CRITICAL boot-order bug (C1): in the +// generated production entry, app middleware was emitted as a static import +// AFTER the authz merge/set happened in the entry's own body. ES modules +// evaluate every static import (including middleware) before the importing +// module's body runs, so a middleware module reading getAuthzCatalog() at its +// own module scope — the SAME eager shape authzMiddleware({ catalog, ... }) +// itself requires, and the same pattern examples/basic-app's +// app/middleware/logger.ts uses for `export default requestLogger({...})` — +// saw an unset catalog and threw, taking the app down at deploy while every +// other gate (typecheck/lint/tests/a plain `bun run build`) stayed green. +// A manual build+boot caught it once; this makes that check permanent. +// +// Fixtures live inside the repo tree, not os.tmpdir(): both the scaffolded +// app files AND the code Bun.build bundles from them import "@wrnexus/authz" +// by bare specifier, which resolves via the root tsconfig.json `paths` map +// walked from the *importing file's* location — an out-of-tree path never +// reaches it. +const scratchRoot = join(import.meta.dir, ".tmp-authz-coldstart"); +mkdirSync(scratchRoot, { recursive: true }); + +afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }); +}); + +test("a module-eval getAuthzCatalog() in app middleware survives a real production cold start", async () => { + const root = mkdtempSync(join(scratchRoot, "app-")); + const appDir = join(root, "app"); + mkdirSync(join(appDir, "api"), { recursive: true }); + mkdirSync(join(appDir, "authz"), { recursive: true }); + mkdirSync(join(appDir, "middleware"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "authz-coldstart-fixture" }), + "utf8", + ); + writeFileSync( + join(appDir, "api", "health.ts"), + `export function GET() { + return Response.json({ ok: true }); +} +`, + "utf8", + ); + writeFileSync( + join(appDir, "authz", "main.ts"), + `import { defineAuthz } from "@wrnexus/authz"; + +export default defineAuthz({ permissions: { "post:read": {} } }); +`, + "utf8", + ); + writeFileSync( + join(appDir, "middleware", "authz-probe.ts"), + `import { authzMiddleware, getAuthzCatalog, memoryPermissionStore } from "@wrnexus/authz"; + +// Module-eval-time read, on purpose: this is exactly the pattern the +// setAuthzCatalog() singleton exists for, and exactly what took the app down +// under the pre-fix boot order. If getAuthzCatalog() throws here, this WHOLE +// MODULE fails to evaluate and the entry crashes at import time, before +// Bun.serve is ever reached. +export default authzMiddleware({ catalog: getAuthzCatalog(), store: memoryPermissionStore() }); +`, + "utf8", + ); + + await runBuild(root); + + const serverPath = join(root, "dist", "server.js"); + const proc = Bun.spawn({ + cmd: ["bun", serverPath], + env: { ...process.env, PORT: "0" }, + stdout: "pipe", + stderr: "pipe", + cwd: root, + }); + + let port: number | undefined; + let stderrText = ""; + try { + const reader = proc.stdout.getReader(); + const errReader = proc.stderr.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + const deadline = Date.now() + 20_000; + const TIMED_OUT = Symbol("timed out"); + while (port === undefined && Date.now() < deadline) { + const outcome = await Promise.race([ + reader.read(), + new Promise((resolve) => setTimeout(() => resolve(TIMED_OUT), 250)), + ]); + if (outcome === TIMED_OUT) continue; + const { value, done } = outcome; + if (done) break; + buffered += decoder.decode(value); + const match = /listening on http:\/\/[^:]+:(\d+)/.exec(buffered); + if (match) port = Number(match[1]); + } + reader.releaseLock(); + + if (port === undefined) { + // Drain stderr for a useful failure message before giving up. + const errOutcome = await Promise.race([ + errReader.read(), + new Promise((resolve) => setTimeout(() => resolve(TIMED_OUT), 500)), + ]); + if (errOutcome !== TIMED_OUT && errOutcome.value) { + stderrText += decoder.decode(errOutcome.value); + } + errReader.releaseLock(); + throw new Error( + `production server never printed a "listening on" line within 20s. stderr:\n${stderrText}`, + ); + } + errReader.releaseLock(); + + const response = await fetch(`http://127.0.0.1:${port}/api/health`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + } finally { + proc.kill(); + await proc.exited; + } +}, 30_000); diff --git a/packages/dev-server/src/authz-boot.ts b/packages/dev-server/src/authz-boot.ts index 4b24b056..6cdbc2a5 100644 --- a/packages/dev-server/src/authz-boot.ts +++ b/packages/dev-server/src/authz-boot.ts @@ -1,5 +1,5 @@ import { pathToFileURL } from "node:url"; -import { buildRouter } from "@wrnexus/router"; +import { buildRouter, type Router } from "@wrnexus/router"; import { emptyCatalog, mergeCatalogs, @@ -8,14 +8,39 @@ import { type CatalogSource, } from "@wrnexus/authz"; +/** Imports one declaration module. Defaults to a raw `import()`; the hot-reload + * call site passes `loadModule` instead (see the note below on why). */ +export type AuthzImporter = (file: string) => Promise<{ default?: AuthzModule }>; + +const rawImport: AuthzImporter = (file) => + import(pathToFileURL(file).href) as Promise<{ default?: AuthzModule }>; + /** * Load and merge every `app/authz/*.ts` declaration. Conflicts throw so a * misconfigured catalog fails the boot rather than silently changing who can * do what. An app with no `app/authz/` directory gets an empty catalog rather * than an error, since not every app uses permissions. + * + * Accepts either an app directory — the original, standalone shape, still + * used by the test suite and by any caller without a router on hand — or an + * already-built `Router`. `startServer` passes its own router (built once at + * `:315` with the full `componentDirs`/`externalRoutes`/`middlewareFiles` + * options) to avoid a second, redundant filesystem scan of the whole `app/` + * tree on every dev boot and on every hot reload of an `app/authz/*.ts` file. + * + * `importModule` defaults to a raw dynamic `import()`, correct for the + * initial boot. On a HOT reload, the caller must instead pass `loadModule` + * (from `./pipeline.ts`): Bun caches local TS/JS modules by filesystem path + * and ignores query strings, so re-`import()`-ing the same absolute path + * after an edit silently returns the stale, already-cached module — + * `loadModule` is what copies an edited file to a versioned sibling path + * specifically to defeat that cache. */ -export async function loadAppAuthzCatalog(appDir: string): Promise { - const router = buildRouter(appDir); +export async function loadAppAuthzCatalog( + appDirOrRouter: string | Router, + importModule: AuthzImporter = rawImport, +): Promise { + const router = typeof appDirOrRouter === "string" ? buildRouter(appDirOrRouter) : appDirOrRouter; if (!router.authz.length) return emptyCatalog(); const sources: CatalogSource[] = []; for (const entry of router.authz) { @@ -24,7 +49,7 @@ export async function loadAppAuthzCatalog(appDir: string): Promise // same failure class as a genuine conflict (a broken/misconfigured catalog), // and letting it propagate fails the boot loudly instead of silently // producing a partial catalog. Do not "helpfully" wrap this in a try/catch. - const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule }; + const imported = await importModule(entry.file); if (!imported.default) { console.warn(`[wrnexus] authz declaration ${entry.file} has no default export; skipping.`); continue; diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index e95a579e..2bd55810 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -32,7 +32,7 @@ import { } from "@wrnexus/db"; import { connectFromConfig } from "@wrnexus/db/connect"; import { configureStorage, type StorageConfig } from "@wrnexus/uploader"; -import { setAuthzCatalog, type AuthzCatalog } from "@wrnexus/authz"; +import { setAuthzCatalog, type AuthzCatalog, type AuthzModule } from "@wrnexus/authz"; import { loadAppAuthzCatalog } from "./authz-boot.ts"; import { realtimeBusFromConfig } from "./realtime-bus.ts"; import { @@ -344,8 +344,11 @@ export async function startServer(opts: ServeOptions): Promise { // the framework never installs one) runs at request time and needs // getAuthzCatalog() already populated by then. An app with no declarations // gets an empty catalog; a genuine conflict between declarations throws and - // fails this boot loudly. - const authzCatalog: AuthzCatalog = await loadAppAuthzCatalog(appDir); + // fails this boot loudly. Pass the already-built `router` (not `appDir`): + // it was just built above with the full componentDirs/externalRoutes/ + // middlewareFiles options, so this avoids a second, redundant filesystem + // scan of the whole app/ tree on every dev boot. + const authzCatalog: AuthzCatalog = await loadAppAuthzCatalog(router); setAuthzCatalog(authzCatalog); // i18n is opt-in by the presence of app/locales/*.json. @@ -615,6 +618,33 @@ export async function startServer(opts: ServeOptions): Promise { middleware.invalidate(); const appFiles = files.filter((file) => !isAbsolute(file)); + // Without this branch, editing app/authz/*.ts reloaded the page (watch.ts + // classifies any non-CSS change as "server") while the OLD catalog stayed + // authoritative — a false security signal: tightening or removing a + // permission LOOKS like it took effect but does not until a restart. A + // raw `import()` here would silently no-op: Bun caches local TS/JS + // modules by filesystem path and ignores query strings, so the edited + // file must be re-imported through `loadModule` (pipeline.ts), which + // copies it to a versioned sibling path specifically to defeat that + // cache — the same mechanism every other hot-reloaded module already + // uses. `router` was just rebuilt above, so this reuses it rather than + // re-scanning the filesystem a third time. + if (appFiles.some((file) => file === "authz" || file.startsWith("authz/"))) { + try { + const nextAuthzCatalog = await loadAppAuthzCatalog( + router, + (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 " + + "until this is fixed and the file saved again", + error, + ); + } + } if (appFiles.some((file) => file === "schemas" || file.startsWith("schemas/"))) { assets.updateSchemas(await schemaRuntime(router)); } @@ -730,5 +760,11 @@ export type { // Deployment: the portable production handler + the node:http adapter. export { createProductionServer, createProductionHandlers } from "./prod.ts"; +// Internal: called only by the generated `.authz-setup.ts` module (see +// packages/cli/src/build.ts) to populate the authorization catalog before any +// other static import — including app middleware — evaluates. Not meant for +// direct use by application code. +export { applyAuthzManifestEarly } from "./prod.ts"; +export type { AuthzManifestEntry } from "./prod.ts"; export { toRequest, writeResponse, nodeListener, serveNode } from "./adapters/node.ts"; export type { FetchHandler } from "./adapters/node.ts"; diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 9b4832fe..9099b513 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -109,10 +109,14 @@ export interface ProdOptions { * `app/authz/*.ts`, statically imported into the generated entry (the * catalog holds policy FUNCTIONS, so — unlike `schemasJs` — it cannot be * JSON-serialised). `module` is `undefined` for a file with no default - * export; `createProductionHandlers` warns and skips it, then merges the - * rest into the process-wide catalog before the server accepts traffic. + * export. In the NORMAL generated-entry build, the catalog is already set + * by the generated `.authz-setup.ts` module before this ever runs (see + * `applyAuthzManifestEarly` below); `createProductionHandlers` merges this + * same list again as an idempotent second pass — with its warnings — so a + * caller that bypasses the generated entry and calls it directly still gets + * a correctly merged catalog. */ - authz?: { source: string; module?: AuthzModule }[]; + authz?: AuthzManifestEntry[]; /** Resolved i18n bundle (default lang + locale messages). */ i18n?: ResolvedI18n; /** Default database connection (driver + url); enables `getDb()`. */ @@ -198,6 +202,40 @@ export function resolveProductionHostname( return environmentHostname?.trim() || explicit || "0.0.0.0"; } +/** One `app/authz/*.ts` declaration as passed through `ProdOptions.authz`. */ +export interface AuthzManifestEntry { + source: string; + /** Undefined when the declaration file has no default export. */ + module?: AuthzModule; +} + +function resolveAuthzSources( + entries: AuthzManifestEntry[], +): { source: string; module: AuthzModule }[] { + return entries.flatMap((entry) => + entry.module ? [{ source: entry.source, module: entry.module }] : [], + ); +} + +/** + * Merge + `setAuthzCatalog` as EARLY as possible, deliberately silently (no + * missing-default-export warnings). Called ONLY from the generated + * `.authz-setup.ts` module that `wrnexus build` imports FIRST in the + * production entry — before any other static import, including app + * middleware — so that a middleware module reading `getAuthzCatalog()` at its + * own module scope (the same eager shape `authzMiddleware({ catalog, ... })` + * itself requires) sees a populated catalog. `createProductionHandlers` below + * performs the exact same merge again, WITH its warnings, as the canonical, + * always-warns second pass — this function stays silent specifically so the + * normal boot path does not print the same "no default export" warning + * twice. A genuine conflict still throws here (via `mergeCatalogs`), which + * fails the boot at import time — before the entry body, and thus + * `createProductionHandlers`, ever runs. + */ +export function applyAuthzManifestEarly(entries: AuthzManifestEntry[]): void { + setAuthzCatalog(mergeCatalogs(resolveAuthzSources(entries))); +} + /** Build the route-matching tables + a module map from the manifest. */ function buildProdRouter(manifest: ProdManifest): { router: Router; @@ -371,14 +409,23 @@ export function createProductionHandlers( // Bun.serve path in createProductionServer below. The app still registers // authzMiddleware itself with its own store; this only makes the merged // catalog reachable. No declarations -> an empty catalog, no error. - const authzSources = (opts.authz ?? []).flatMap((entry) => { - if (!entry.module) { - console.warn(`[wrnexus] authz declaration ${entry.source} has no default export; skipping.`); - return []; - } - return [{ source: entry.source, module: entry.module }]; - }); - const authzCatalog = mergeCatalogs(authzSources); + // + // In the NORMAL generated-entry build, this is a deliberately redundant + // SECOND pass: the generated `.authz-setup.ts` module already ran this + // exact merge (silently, via applyAuthzManifestEarly above) before this + // 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. + 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); // Middleware is already an ordered array of functions. diff --git a/packages/dev-server/test/authz-prod.test.ts b/packages/dev-server/test/authz-prod.test.ts new file mode 100644 index 00000000..71a753f1 --- /dev/null +++ b/packages/dev-server/test/authz-prod.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; +import { defineAuthz, getAuthzCatalog } from "@wrnexus/authz"; +import { + applyAuthzManifestEarly, + createProductionHandlers, + type ProdManifest, +} from "../src/prod.ts"; + +const EMPTY_MANIFEST: ProdManifest = { + pages: [], + api: [], + realtime: [], + middleware: [], + components: [], + 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); + + createProductionHandlers(EMPTY_MANIFEST, {}); + expect(getAuthzCatalog().permissions.size).toBe(0); + }); + + test("a declaration with no default export warns and is skipped, not fatal", () => { + const originalWarn = console.warn; + const warnings: unknown[][] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + try { + createProductionHandlers(EMPTY_MANIFEST, { + authz: [ + { source: "broken.ts", module: undefined }, + { + source: "ok.ts", + module: defineAuthz({ permissions: { "post:read": {} } }), + }, + ], + }); + } finally { + console.warn = originalWarn; + } + expect(getAuthzCatalog().permissions.has("post:read")).toBe(true); + expect(getAuthzCatalog().permissions.size).toBe(1); + expect(warnings.some((args) => args.some((arg) => String(arg).includes("broken.ts")))).toBe( + true, + ); + }); + + test("a conflicting pair of declarations throws, naming both source files", () => { + 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/); + + let thrown: unknown; + try { + createProductionHandlers(EMPTY_MANIFEST, { + authz: [ + { + source: "a.ts", + module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }), + }, + { + source: "b.ts", + module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }), + }, + ], + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + expect(message).toContain("a.ts"); + expect(message).toContain("b.ts"); + }); + + test("calling createProductionHandlers a second time with different declarations re-validates, not skips", () => { + // Regression guard for the "skip merging if a catalog is already set" + // trap: since setAuthzCatalog is a process-wide singleton, an earlier + // test (or an earlier createProductionHandlers call in the same process) + // can leave hasAuthzCatalog() true. This call must still independently + // merge+validate its OWN opts.authz, not silently trust a stale catalog + // left over from something else. + createProductionHandlers(EMPTY_MANIFEST, { + authz: [{ source: "first.ts", module: defineAuthz({ permissions: { "a:read": {} } }) }], + }); + expect(getAuthzCatalog().permissions.has("a:read")).toBe(true); + + createProductionHandlers(EMPTY_MANIFEST, { + authz: [{ source: "second.ts", module: defineAuthz({ permissions: { "b:read": {} } }) }], + }); + expect(getAuthzCatalog().permissions.has("a:read")).toBe(false); + expect(getAuthzCatalog().permissions.has("b:read")).toBe(true); + }); +}); + +describe("applyAuthzManifestEarly (the eager, silent pass called only by the generated .authz-setup.ts)", () => { + test("sets the catalog from valid declarations", () => { + applyAuthzManifestEarly([ + { source: "early.ts", module: defineAuthz({ permissions: { "early:read": {} } }) }, + ]); + expect(getAuthzCatalog().permissions.has("early:read")).toBe(true); + }); + + test("silently skips a missing default export — no warning, no throw", () => { + const originalWarn = console.warn; + let warnCalls = 0; + console.warn = () => { + warnCalls++; + }; + try { + expect(() => + applyAuthzManifestEarly([{ source: "broken.ts", module: undefined }]), + ).not.toThrow(); + } finally { + console.warn = originalWarn; + } + expect(warnCalls).toBe(0); + expect(getAuthzCatalog().permissions.size).toBe(0); + }); + + test("still throws on a genuine conflict (fatal either way, just earlier)", () => { + expect(() => + applyAuthzManifestEarly([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) }, + ]), + ).toThrow(/WRN-AUTHZ-CONFLICT/); + }); +}); diff --git a/packages/dev-server/test/authz-startserver.test.ts b/packages/dev-server/test/authz-startserver.test.ts index 5644076d..a4ca0251 100644 --- a/packages/dev-server/test/authz-startserver.test.ts +++ b/packages/dev-server/test/authz-startserver.test.ts @@ -34,6 +34,19 @@ afterAll(() => { rmSync(scratchRoot, { recursive: true, force: true }); }); +async function waitFor( + condition: () => boolean, + timeoutMs: number, + intervalMs = 50, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + if (!condition()) throw new Error(`waitFor: condition was not met within ${timeoutMs}ms`); +} + describe("dev boot loads the authz catalog before middleware is resolved", () => { test("an app with declarations makes getAuthzCatalog() return them after boot", async () => { const appDir = scaffold("has-decls", { @@ -100,4 +113,40 @@ export default defineAuthz({ permissions: { "post:read": { risk: "high" } } });` expect(message).toContain(join(appDir, "authz", "a.ts")); expect(message).toContain(join(appDir, "authz", "b.ts")); }); + + test("editing a declaration in a RUNNING dev server updates the live catalog, via the real file watcher", async () => { + const appDir = scaffold("hmr-live", { + "main.ts": `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": {} } });`, + }); + const server = await startServer({ + appDir, + hostname: "127.0.0.1", + port: 0, + mode: "development", + hmr: true, + }); + try { + expect(getAuthzCatalog().permissions.has("post:read")).toBe(true); + expect(getAuthzCatalog().permissions.has("post:write")).toBe(false); + + // A real write to disk, picked up by the real fs watcher (watch.ts / + // startWatcher) — not a direct call into any internal hot-update + // function. This is the only way to prove the wiring actually works, + // as opposed to proving only that the code branch exists. + writeFileSync( + join(appDir, "authz", "main.ts"), + `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:write": {} } });`, + "utf8", + ); + + await waitFor(() => getAuthzCatalog().permissions.has("post:write"), 10_000); + + expect(getAuthzCatalog().permissions.has("post:write")).toBe(true); + expect(getAuthzCatalog().permissions.has("post:read")).toBe(false); + } finally { + server.stop(); + } + }, 15_000); });