Round-1 review fixes for Task 13: - packages/cli/package.json was missing @wrnexus/authz, and packages/authz/package.json was missing @wrnexus/core despite importing its types in index.ts/middleware.ts/advanced.ts. Both only worked in-repo because bare "@wrnexus/*" specifiers resolve through the root tsconfig.json paths map; a standalone install of @wrnexus/cli or @wrnexus/authz would fail at runtime. - authz.ts's unknown/missing-subcommand and bad --dialect paths now console.error + process.exit(1), matching db.ts's convention, instead of throwing — index.ts's top-level catch previously printed those as a raw stack trace. Added a subprocess-level test that spawns the real CLI and asserts stderr has the usage line with no stack frame. - nextMigrationNumber now extracts the leading-digit run the same way db/migrate.ts's nextNumber does, instead of a fixed slice(0, 4) that would have undercounted once a migration number passed 9999.
134 lines
5.1 KiB
TypeScript
134 lines
5.1 KiB
TypeScript
/**
|
|
* `wrnexus authz <cmd>` — 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 <list|generate|init>";
|
|
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<AuthzCatalog> {
|
|
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=<value>` 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<void> {
|
|
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);
|
|
}
|
|
}
|