catalog
Export the permission catalog as JSON, JSON Schema or Markdown, and the shape of the catalog document.
permdock catalog turns the merged permission definition into documents that people, CI and agents read without running the app. It is the read-only sibling of collect: collect scans source and writes permissions.catalog.json; catalog renders that catalog (or the runtime definition directly) in the requested format.
Usage
permdock catalog --format json > permissions.catalog.json
permdock catalog --format schema > permissions.schema.json # JSON Schema of the catalog document
permdock catalog --format markdown > docs/permissions.md
permdock catalog --from ./src/permissions.ts # render straight from the definition module, skipping collect
permdock catalog --include post --include billing.invoice # scope to a subtreeWhere the data comes from
Permission leaves are plain JSON and the definition is a runtime value, so the catalog is produced by importing the definition module and walking listPermissions(permissions). Resource schemas are exported through Standard JSON Schema (~standard.jsonSchema.output(...)) when the validator supports it (Zod, Valibot and ArkType do); resources whose validator does not implement Standard JSON Schema get schema: null and a warning.
Usage sites and defining files come from the source scan; usages is always present and empty when no call site was found.
Catalog JSON shape
{
"$schema": "https://permdock.com/schemas/catalog-v1.json",
"version": 1,
"generatedAt": "2026-09-06T10:00:00Z",
"generator": "permdock@0.1.0",
"resources": {
"post": {
"id": "id",
"schema": {
"type": "object",
"properties": { "id": { "type": "string" } }
},
"definedIn": "features/posts/permissions.ts"
}
},
"permissions": [
{
"key": "post.update",
"scope": "post:update",
"resource": "post",
"action": "update",
"arity": "instance",
"meta": { "title": "Update post", "description": "Edit a post you own" },
"usages": [
{ "file": "app/posts/[id]/page.tsx", "line": 42, "call": "assert" },
{ "file": "features/posts/policy.ts", "line": 12, "call": "allow" }
]
},
{
"key": "post.create",
"scope": "post:create",
"resource": "post",
"action": "create",
"arity": "collection",
"meta": {},
"usages": []
}
]
}Notes on the shape:
permissionsis a flat array sorted bykey; nesting (billing.invoice.pay) is expressed in the dotted key, andresourceholds the dotted resource path.arityisinstanceorcollection.metais whatever the definition attached to the action (title,description,tags, or any JSON); it is what MCP tool descriptions and Markdown output use.schemais a JSON Schema document; thetarget(draft-2020-12,openapi-3.0) is selectable with--schema-target.- Without a configured
policy, the catalog has no grants, role details or conditions. With one,grantslists every code grant with its portable conditions, approval, fields and validity, in a canonical order (wire formats); closures are markedportable: falseand never serialised. diff compares that section between two versions. - The
$schemaURL is the published JSON Schema of the catalog format, which is the same document--format schemaemits, so agents can validate a catalog offline. The exact hostname is fixed when the docs site launches.
Reading a catalog
A package that reads the catalog at run time imports permdock/catalog, a runtime entry with no CLI code. A build script finds the file with catalogPath(config, cwd) from permdock/cli.
import { readFileSync } from "node:fs";
import { catalogPath } from "permdock/cli";
import { parseCatalog, rowConditionKeys } from "permdock/catalog";
import config from "./permdock.config.ts";
const catalog = parseCatalog(
readFileSync(catalogPath(config, process.cwd()), "utf8"),
);
const conditioned = rowConditionKeys(catalog);
const sqlEnforceable = catalog.permissions.filter(
(permission) => !conditioned.has(permission.key),
);parseCatalog(json)takes the JSON text or the parsed value. It checks it againstschemas/catalog-v1.jsonand returns a deep-frozen copy built from own keys. A__proto__key stays an ordinary key, and an object with any other prototype, a cycle or a value JSON cannot carry is rejected. On failure it throwsPermDockValidationErrorwithcode: 'invalid-data',boundary: 'catalog'and every issue with itspath.rowConditionKeys(catalog)is the set of keys markedrowConditions: true: permissions whose code grants depend on more than role and scope, so a policy that calls only the SQL helpers would widen access (RLS).catalogPath(config, cwd, out?)resolves the file the same waycollectwrites it:out, thencollect.out, thencatalog.out, thenpermissions.catalog.json.- The types (
CatalogDocument,CatalogPermission,CatalogResource,CatalogRole,CatalogScope,CatalogGrant,CatalogValidity,CatalogApproval,CatalogBreakGlass,CatalogActivation,CatalogSupportAccess,CatalogUsage) are exported from bothpermdock/catalogandpermdock/cli.
The reader, the $schema file and --format schema come from one schema constant, and a test checks the committed catalogs against both the reader and Ajv.
Markdown output
--format markdown renders one section per resource with a table of actions, arity, scope and metadata, and (when present) usage counts. It is intended to be committed under docs/ or pasted into a PR so reviewers can see the permission surface change without reading TypeScript.
Consumers
- openapi reads the catalog to emit
securitySchemesscopes andx-permdock-permissions. - The MCP adapter uses
metafor tool descriptions andarityto decide whether adataloader is required. - The docs MCP server (
search,list_pagesandget_pageat/mcp) andllms.txtexpose the Markdown output so agents can ask "which permissions exist?". permdock/testingcompares a policy matrix against the catalog to find permissions with no test.
Related
Last updated on
collect
Scan source paths for definePermissions() calls and permission usages, write the catalog and generated barrel, and fail CI on drift.
diff
Compare two policies or catalogs, list the permissions, roles, scopes and grants that changed, run fixtures through both with --impact, and exit 1 on a breaking change.