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:
@@ -8,6 +8,7 @@
|
|||||||
"./serve-entry": "./src/serve-entry.ts"
|
"./serve-entry": "./src/serve-entry.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/dev-toolbar": "workspace:*",
|
"@wrnexus/dev-toolbar": "workspace:*",
|
||||||
"@wrnexus/router": "workspace:*",
|
"@wrnexus/router": "workspace:*",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user