feat(dev-server): load the authz catalog at boot

Adds loadAppAuthzCatalog(appDir) to @wrnexus/dev-server: discovers
app/authz/*.ts declarations via buildRouter, imports and merges them
into an AuthzCatalog, returning an empty catalog when the app has no
declarations. A declaration with no default export is skipped with a
warning; a genuine conflict between two declarations throws
WRN-AUTHZ-CONFLICT naming both source files.

Declared the missing @wrnexus/authz workspace dependency in
dev-server's package.json.
This commit is contained in:
2026-08-04 22:03:28 +05:30
parent bc5437063d
commit daea59cf5d
3 changed files with 94 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@
"./serve-entry": "./src/serve-entry.ts"
},
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/dev-toolbar": "workspace:*",
"@wrnexus/router": "workspace:*",
+31
View File
@@ -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<AuthzCatalog> {
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);
}
@@ -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);
});
});