From b9098382b30af260ff61d0fe93e9d1bb3f0ec793 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 21:34:56 +0530 Subject: [PATCH] feat(cli): add wrnexus authz list/generate/init Introspects the merged authz catalog, emits app/authz/permissions.gen.ts type unions, and scaffolds the assignment-table migration. init validates --dialect explicitly (unrecognised values reject rather than silently falling back to sqlite) and joins authzMigrationSql's up/down statement lists with terminators instead of interpolating the arrays. Test scaffolding for dynamically-imported app/authz declarations must live inside the repo tree (not os.tmpdir()) for the "@wrnexus/*" bare specifier to resolve via tsconfig paths; .gitignore excludes the scratch dirs this produces. --- .gitignore | 5 + packages/cli/src/authz.ts | 123 ++++++++++++++++ packages/cli/src/index.ts | 9 ++ packages/cli/test/authz-command.test.ts | 188 ++++++++++++++++++++++++ 4 files changed, 325 insertions(+) create mode 100644 packages/cli/src/authz.ts create mode 100644 packages/cli/test/authz-command.test.ts diff --git a/.gitignore b/.gitignore index f03ba3ac..26bd7488 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ bun.lockb # Local focused typecheck helpers must never enter the repository. focus-shims.d.ts tsconfig.focus.json + +# Scratch dirs for tests that must dynamically import scaffolded files using +# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`, +# which requires the scaffold to live inside the repo tree). +**/test/.tmp-*/ diff --git a/packages/cli/src/authz.ts b/packages/cli/src/authz.ts new file mode 100644 index 00000000..8af48500 --- /dev/null +++ b/packages/cli/src/authz.ts @@ -0,0 +1,123 @@ +/** + * `wrnexus authz ` — authorization catalog tooling. + * + * wrnexus authz list print every registered permission, role, and policy + * wrnexus authz generate write app/authz/permissions.gen.ts type unions + * wrnexus authz init [--dialect=sqlite|postgres|mysql] + * scaffold the assignment-table migration + */ + +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { buildRouter } from "@wrnexus/router"; +import { + generatePermissionTypes, + mergeCatalogs, + type AuthzCatalog, + type AuthzModule, + type CatalogSource, +} from "@wrnexus/authz"; +import { authzMigrationSql } from "@wrnexus/authz/db"; +import type { Dialect } from "@wrnexus/db"; + +const USAGE = "usage: wrnexus authz "; +const DIALECTS = ["sqlite", "postgres", "mysql"] as const; + +/** Import every app/authz declaration and merge it into one catalog. */ +export async function loadAuthzCatalog(appDir: string): Promise { + const router = buildRouter(appDir); + const sources: CatalogSource[] = []; + for (const entry of router.authz) { + const imported = (await import(pathToFileURL(entry.file).href)) as { + default?: AuthzModule; + }; + if (!imported.default) { + console.warn(`[wrnexus] ${entry.file} has no default export; skipping`); + continue; + } + sources.push({ source: entry.file, module: imported.default }); + } + return mergeCatalogs(sources); +} + +function nextMigrationNumber(dir: string): string { + if (!existsSync(dir)) return "0001"; + const numbers = readdirSync(dir) + .map((name) => Number.parseInt(name.slice(0, 4), 10)) + .filter((value) => Number.isInteger(value)); + return String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(4, "0"); +} + +/** Parse `--dialect=` from CLI args. Defaults to sqlite; rejects unknown values. */ +function resolveDialect(args: string[]): Dialect { + const flag = args.find((arg) => arg.startsWith("--dialect=")); + if (!flag) return "sqlite"; + const value = flag.split("=")[1]; + if ((DIALECTS as readonly string[]).includes(value ?? "")) return value as Dialect; + throw new Error( + `WRN-AUTHZ-INIT: unrecognised --dialect='${value}'. Use one of: ${DIALECTS.join(", ")}.`, + ); +} + +export async function runAuthzCommand( + root: string, + sub: string | undefined, + args: string[], +): Promise { + const appDir = join(resolve(root), "app"); + + switch (sub) { + case "list": { + const catalog = await loadAuthzCatalog(appDir); + console.log(`Permissions (${catalog.permissions.size}):`); + for (const [id, meta] of [...catalog.permissions].sort()) { + const tags = [meta.risk && `risk=${meta.risk}`, meta.public && "public"] + .filter(Boolean) + .join(" "); + console.log(` ${id}${meta.title ? ` — ${meta.title}` : ""}${tags ? ` [${tags}]` : ""}`); + } + console.log(`\nRoles (${catalog.roles.size}):`); + for (const [name, grants] of [...catalog.roles].sort()) { + console.log(` ${name} → ${grants.join(", ") || "(nothing)"}`); + } + console.log(`\nPolicies (${catalog.policies.size}):`); + for (const name of [...catalog.policies.keys()].sort()) { + const bound = [...catalog.bindings] + .filter(([, names]) => names.includes(name)) + .map(([permission]) => permission); + console.log(` ${name}${bound.length ? ` → ${bound.join(", ")}` : " (unbound)"}`); + } + return; + } + + case "generate": { + const catalog = await loadAuthzCatalog(appDir); + const target = join(appDir, "authz", "permissions.gen.ts"); + mkdirSync(join(appDir, "authz"), { recursive: true }); + writeFileSync(target, generatePermissionTypes(catalog), "utf8"); + console.log( + `Wrote ${target} (${catalog.permissions.size} permissions, ${catalog.roles.size} roles)`, + ); + return; + } + + case "init": { + const dialect = resolveDialect(args); + const dir = join(appDir, "db", "migrations"); + mkdirSync(dir, { recursive: true }); + const { up, down } = authzMigrationSql(dialect); + const file = join(dir, `${nextMigrationNumber(dir)}_authz_tables.sql`); + // up/down are statement LISTS; interpolating the arrays directly would + // comma-join them into one unparseable statement. + const block = (statements: string[]) => statements.map((s) => `${s};`).join("\n\n"); + writeFileSync(file, `-- +up\n${block(up)}\n\n-- +down\n${block(down)}\n`, "utf8"); + console.log(`Wrote ${file}`); + console.log("Run `wrnexus db migrate` to apply it."); + return; + } + + default: + throw new Error(USAGE); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8d87a994..a1b003be 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,7 @@ * wrnexus create scaffold a new app * wrnexus eject copy a Wire UI component into your app * wrnexus db database migrations + * wrnexus authz authorization catalog tooling */ import { join, resolve } from "node:path"; @@ -66,6 +67,7 @@ Usage: wrnexus eject Copy a Wire UI component into app/components wrnexus update [dir] [--latest] Upgrade deps, migrate project files, and verify the app wrnexus db Migrations: migrate | rollback | status | seed | generate | new + wrnexus authz Authorization: list | generate | init [--dialect=sqlite|postgres|mysql] wrnexus test [level] [app-dir] [--watch] Run unit | component | api | browser | visual | accessibility | performance wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files @@ -270,6 +272,13 @@ async function main(): Promise { await runDbCommand(".", sub, dbArgs); break; } + case "authz": { + bootstrapProfile(".", "development", rest); + const { runAuthzCommand } = await import("./authz.ts"); + const [sub, ...authzArgs] = rest.filter((a) => !a.startsWith("--profile=")); + await runAuthzCommand(".", sub, authzArgs); + break; + } case "profiles": { const { listProfiles } = await import("./profiles.ts"); await listProfiles(rest.find((a) => !a.startsWith("--")) ?? "."); diff --git a/packages/cli/test/authz-command.test.ts b/packages/cli/test/authz-command.test.ts new file mode 100644 index 00000000..1e6eb958 --- /dev/null +++ b/packages/cli/test/authz-command.test.ts @@ -0,0 +1,188 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync, + existsSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import { loadAuthzCatalog, runAuthzCommand } from "../src/authz.ts"; + +// Declarations under app/authz/ import "@wrnexus/authz" with a bare specifier, +// which Bun resolves via the root tsconfig.json `paths` map by walking up from +// the imported file's directory. os.tmpdir() lives outside the repo tree (often +// on a different drive on Windows), so that walk never reaches the root +// tsconfig.json and the dynamic import fails with "Cannot find module +// '@wrnexus/authz'". Scaffolding under this test file's own directory keeps the +// walk-up inside the repo, exactly like a real app (which has its own +// node_modules/tsconfig with @wrnexus/authz installed). +const scratchRoot = join(import.meta.dir, ".tmp-authz-cli"); +const createdRoots: string[] = []; + +function scaffold(): string { + mkdirSync(scratchRoot, { recursive: true }); + const root = mkdtempSync(join(scratchRoot, "run-")); + createdRoots.push(root); + mkdirSync(join(root, "app", "authz"), { recursive: true }); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + writeFileSync( + join(root, "app", "authz", "blog.ts"), + `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ + permissions: { "post:read": { title: "View posts" }, "post:write": {} }, + roles: { editor: ["post:*"] }, +}); +`, + "utf8", + ); + return root; +} + +afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }); +}); + +describe("wrnexus authz", () => { + test("loadAuthzCatalog merges every declaration", async () => { + const catalog = await loadAuthzCatalog(join(scaffold(), "app")); + expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]); + expect([...catalog.roles.keys()]).toEqual(["editor"]); + }); + + test("generate writes the permission types file", async () => { + const root = scaffold(); + await runAuthzCommand(root, "generate", []); + const generated = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8"); + expect(generated).toContain('export type Permission = "post:read" | "post:write";'); + }); + + test("init writes a migration containing both tables", async () => { + const root = scaffold(); + mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); + await runAuthzCommand(root, "init", []); + const dir = join(root, "app", "db", "migrations"); + const file = readdirSync(dir).find((name: string) => name.includes("authz")); + expect(file).toBeDefined(); + const sql = readFileSync(join(dir, file!), "utf8"); + expect(sql).toContain("_wrn_authz_assignment"); + expect(sql).toContain("_wrn_authz_grant"); + expect(sql).toContain("-- +down"); + }); + + test("list prints every permission and role", async () => { + const root = scaffold(); + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => void lines.push(args.join(" ")); + try { + await runAuthzCommand(root, "list", []); + } finally { + console.log = original; + } + const output = lines.join("\n"); + expect(output).toContain("post:read"); + expect(output).toContain("editor"); + }); + + test("an unknown subcommand throws with usage", async () => { + await expect(runAuthzCommand(scaffold(), "bogus", [])).rejects.toThrow(/usage/i); + }); + + test("a missing subcommand throws with usage", async () => { + await expect(runAuthzCommand(scaffold(), undefined, [])).rejects.toThrow(/usage/i); + }); + + test("list does not crash on an app with no app/authz directory", async () => { + mkdirSync(scratchRoot, { recursive: true }); + const root = mkdtempSync(join(scratchRoot, "empty-")); + createdRoots.push(root); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => void lines.push(args.join(" ")); + try { + await runAuthzCommand(root, "list", []); + } finally { + console.log = original; + } + expect(lines.join("\n")).toContain("Permissions (0)"); + }); + + test("loadAuthzCatalog warns and skips a declaration file with no default export", async () => { + const root = scaffold(); + writeFileSync(join(root, "app", "authz", "empty.ts"), `export const notDefault = 1;\n`, "utf8"); + const warnings: unknown[][] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => void warnings.push(args); + try { + const catalog = await loadAuthzCatalog(join(root, "app")); + expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]); + } finally { + console.warn = originalWarn; + } + expect(warnings.some((args) => String(args.join(" ")).includes("no default export"))).toBe( + true, + ); + }); + + test("generate is idempotent when run twice", async () => { + const root = scaffold(); + await runAuthzCommand(root, "generate", []); + const first = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8"); + await runAuthzCommand(root, "generate", []); + const second = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8"); + expect(second).toBe(first); + }); + + test("init --dialect=postgres emits postgres DDL", async () => { + const root = scaffold(); + mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); + await runAuthzCommand(root, "init", ["--dialect=postgres"]); + const dir = join(root, "app", "db", "migrations"); + const file = readdirSync(dir).find((name: string) => name.includes("authz")); + const sql = readFileSync(join(dir, file!), "utf8"); + expect(sql).toContain("SERIAL PRIMARY KEY"); + }); + + test("init --dialect=mysql emits mysql DDL", async () => { + const root = scaffold(); + mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); + await runAuthzCommand(root, "init", ["--dialect=mysql"]); + const dir = join(root, "app", "db", "migrations"); + const file = readdirSync(dir).find((name: string) => name.includes("authz")); + const sql = readFileSync(join(dir, file!), "utf8"); + expect(sql).toContain("AUTO_INCREMENT PRIMARY KEY"); + }); + + test("init with an unrecognised --dialect= does not silently fall back to sqlite", async () => { + const root = scaffold(); + mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); + await expect(runAuthzCommand(root, "init", ["--dialect=oracle"])).rejects.toThrow(/dialect/i); + }); + + test("init writes a migration that the migration runner can parse", async () => { + const { loadMigrations } = await import("@wrnexus/db"); + const root = scaffold(); + const dir = join(root, "app", "db", "migrations"); + mkdirSync(dir, { recursive: true }); + await runAuthzCommand(root, "init", []); + const migrations = loadMigrations(dir); + expect(migrations.length).toBe(1); + const migration = migrations[0]!; + expect(migration.up).toContain("_wrn_authz_assignment"); + expect(migration.up).toContain("_wrn_authz_grant"); + expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_grant"); + expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_assignment"); + }); + + test("generate does not clobber a real declaration file", async () => { + const root = scaffold(); + await runAuthzCommand(root, "generate", []); + expect(existsSync(join(root, "app", "authz", "blog.ts"))).toBe(true); + const original = readFileSync(join(root, "app", "authz", "blog.ts"), "utf8"); + expect(original).toContain("defineAuthz"); + }); +});