diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index f29e0a5d..b01a003e 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -505,6 +505,7 @@ "expandRoles", "filterAuthorized", "filterCan", + "generatePermissionTypes", "guardPermission", "hasRole", "memoryAuditSink", diff --git a/packages/authz/src/codegen.ts b/packages/authz/src/codegen.ts new file mode 100644 index 00000000..144f5a19 --- /dev/null +++ b/packages/authz/src/codegen.ts @@ -0,0 +1,26 @@ +import type { AuthzCatalog } from "./types.ts"; + +function union(values: string[]): string { + if (!values.length) return "never"; + // JSON.stringify escapes backslashes, quotes, and control characters + // (including raw newlines, which the registry does not reject in role + // names and which would otherwise break out of the string literal). + return values + .slice() + .sort() + .map((value) => JSON.stringify(value)) + .join(" | "); +} + +/** + * Emit compile-time unions for the registered permissions and roles, so a + * typo in can(ctx, "post:wrtie") is a type error rather than a silent false. + */ +export function generatePermissionTypes(catalog: AuthzCatalog): string { + return `// Generated by \`wrnexus authz generate\`. DO NOT EDIT. + +export type Permission = ${union([...catalog.permissions.keys()])}; + +export type Role = ${union([...catalog.roles.keys()])}; +`; +} diff --git a/packages/authz/src/index.ts b/packages/authz/src/index.ts index 08f8101f..a05b6d20 100644 --- a/packages/authz/src/index.ts +++ b/packages/authz/src/index.ts @@ -167,3 +167,4 @@ export type { SubjectAssignments, } from "./types.ts"; export type { AuthorizeDecisionOptions } from "./advanced.ts"; +export { generatePermissionTypes } from "./codegen.ts"; diff --git a/packages/authz/test/codegen.test.ts b/packages/authz/test/codegen.test.ts new file mode 100644 index 00000000..a025413f --- /dev/null +++ b/packages/authz/test/codegen.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { defineAuthz } from "../src/registry.ts"; +import { mergeCatalogs, emptyCatalog } from "../src/catalog.ts"; +import { generatePermissionTypes } from "../src/codegen.ts"; + +describe("generatePermissionTypes", () => { + test("emits sorted Permission and Role unions", () => { + const catalog = mergeCatalogs([ + { + source: "t.ts", + module: defineAuthz({ + permissions: { "post:write": {}, "post:read": {} }, + roles: { editor: ["post:*"], admin: ["*"] }, + }), + }, + ]); + const out = generatePermissionTypes(catalog); + expect(out).toContain('export type Permission = "post:read" | "post:write";'); + expect(out).toContain('export type Role = "admin" | "editor";'); + expect(out).toContain("DO NOT EDIT"); + }); + + test("emits never for an empty catalog so the file still typechecks", () => { + const out = generatePermissionTypes(emptyCatalog()); + expect(out).toContain("export type Permission = never;"); + expect(out).toContain("export type Role = never;"); + }); + + test("escapes quotes in identifiers", () => { + const catalog = mergeCatalogs([{ source: "t.ts", module: { roles: { 'we"ird': [] } } }]); + expect(generatePermissionTypes(catalog)).toContain('"we\\"ird"'); + }); +});