/** * `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); } /** Print a message and exit non-zero, matching db.ts's convention for user-facing * CLI errors: never throw, so index.ts's generic `main().catch` handler (which * prints the raw error, stack and all) is never reached for an expected failure. */ function fail(message: string): never { console.error(message); process.exit(1); } // Same leading-digit extraction as db/migrate.ts's `nextNumber`: a fixed // `slice(0, 4)` would undercount once a migration number grows past 9999. function nextMigrationNumber(dir: string): string { if (!existsSync(dir)) return "0001"; let max = 0; for (const name of readdirSync(dir)) { const match = /^(\d+)/.exec(name); if (match) max = Math.max(max, Number(match[1])); } return String(max + 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; return fail(`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: fail(USAGE); } }