feat(authz): generate Permission and Role union types
Emits sorted TS unions from the merged catalog so a typo in can(ctx, "post:wrtie") is a compile-time error. Uses JSON.stringify for string-literal escaping (not manual backslash/quote replace) so role names containing raw newlines still produce valid TypeScript; role names are not regex-validated like permission ids, so this matters for the raw mergeCatalogs path.
This commit is contained in:
@@ -505,6 +505,7 @@
|
|||||||
"expandRoles",
|
"expandRoles",
|
||||||
"filterAuthorized",
|
"filterAuthorized",
|
||||||
"filterCan",
|
"filterCan",
|
||||||
|
"generatePermissionTypes",
|
||||||
"guardPermission",
|
"guardPermission",
|
||||||
"hasRole",
|
"hasRole",
|
||||||
"memoryAuditSink",
|
"memoryAuditSink",
|
||||||
|
|||||||
@@ -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()])};
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -167,3 +167,4 @@ export type {
|
|||||||
SubjectAssignments,
|
SubjectAssignments,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
export type { AuthorizeDecisionOptions } from "./advanced.ts";
|
export type { AuthorizeDecisionOptions } from "./advanced.ts";
|
||||||
|
export { generatePermissionTypes } from "./codegen.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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user