diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 33256894..da910339 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -8,6 +8,7 @@ "./serve-entry": "./src/serve-entry.ts" }, "dependencies": { + "@wrnexus/authz": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/dev-toolbar": "workspace:*", "@wrnexus/router": "workspace:*", diff --git a/packages/dev-server/src/authz-boot.ts b/packages/dev-server/src/authz-boot.ts new file mode 100644 index 00000000..d22022e7 --- /dev/null +++ b/packages/dev-server/src/authz-boot.ts @@ -0,0 +1,31 @@ +import { pathToFileURL } from "node:url"; +import { buildRouter } from "@wrnexus/router"; +import { + emptyCatalog, + mergeCatalogs, + type AuthzCatalog, + type AuthzModule, + type CatalogSource, +} from "@wrnexus/authz"; + +/** + * 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. + */ +export async function loadAppAuthzCatalog(appDir: string): Promise { + const router = buildRouter(appDir); + if (!router.authz.length) return emptyCatalog(); + const sources: CatalogSource[] = []; + for (const entry of router.authz) { + // buildRouter already skips *.gen.ts, so only real declarations arrive here. + const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule }; + if (!imported.default) { + console.warn(`[wrnexus] authz declaration ${entry.file} has no default export; skipping.`); + continue; + } + sources.push({ source: entry.file, module: imported.default }); + } + return mergeCatalogs(sources); +} diff --git a/packages/dev-server/test/authz-boot.test.ts b/packages/dev-server/test/authz-boot.test.ts new file mode 100644 index 00000000..52240762 --- /dev/null +++ b/packages/dev-server/test/authz-boot.test.ts @@ -0,0 +1,62 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadAppAuthzCatalog } from "../src/authz-boot.ts"; + +// Fixtures must live inside the repo tree, not os.tmpdir(). A scaffolded file +// under app/authz importing "@wrnexus/authz" by bare specifier resolves via +// the root tsconfig.json `paths` map, walked from the *imported file's* +// location — an out-of-tree path (os.tmpdir(), often a different drive on +// Windows) never reaches it and fails to resolve the module. +const scratchRoot = join(import.meta.dir, ".tmp-authz-boot"); +mkdirSync(scratchRoot, { recursive: true }); + +function scaffold(body: string): string { + const root = mkdtempSync(join(scratchRoot, "app-")); + mkdirSync(join(root, "app", "authz"), { recursive: true }); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + writeFileSync(join(root, "app", "authz", "main.ts"), body, "utf8"); + return join(root, "app"); +} + +afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }); +}); + +describe("loadAppAuthzCatalog", () => { + test("loads declarations from app/authz", async () => { + const appDir = scaffold( + `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": {} } });`, + ); + const catalog = await loadAppAuthzCatalog(appDir); + expect(catalog.permissions.has("post:read")).toBe(true); + }); + + test("an app with no declarations gets an empty catalog rather than an error", async () => { + const root = mkdtempSync(join(scratchRoot, "empty-")); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + const catalog = await loadAppAuthzCatalog(join(root, "app")); + expect(catalog.permissions.size).toBe(0); + }); + + test("a conflicting declaration fails the boot loudly", async () => { + const appDir = scaffold( + `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": { risk: "low" } } });`, + ); + writeFileSync( + join(appDir, "authz", "other.ts"), + `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": { risk: "high" } } });`, + "utf8", + ); + await expect(loadAppAuthzCatalog(appDir)).rejects.toThrow(/WRN-AUTHZ-CONFLICT/); + }); + + test("a declaration with no default export is skipped, not fatal", async () => { + const appDir = scaffold(`export const notDefault = 1;`); + const catalog = await loadAppAuthzCatalog(appDir); + expect(catalog.permissions.size).toBe(0); + }); +});