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. collect scans source and writes permissions.catalog.json; catalog builds the same catalog, or reads the runtime definition with --permissions, and renders it in the requested format.
Usage
permdock catalog --out permissions.catalog.json
permdock catalog --kind json-schema --out permissions.schema.json # JSON Schema of the catalog document
permdock catalog --kind markdown --out docs/permissions.md
permdock catalog --kind markdown --out docs/permissions.md --check # CI: exit 1 when the file is stale
permdock catalog --permissions ./src/permissions.ts # render straight from the definition module, skipping collect
permdock catalog --include post --include billing.invoice # scope to a subtree| Flag | Meaning |
|---|---|
--kind <kind> | catalog (default), json-schema or markdown |
--permissions <module> | The definition module to read; default permissions in permdock.config.ts |
--include <key> | Permission keys or prefixes to keep, repeatable |
--out <file> | Write the document to this file, with a Created or Updated line; without it, print it on stdout |
--check | With --out: exit 1 and print a diff when the file differs; write nothing |
Where 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.4.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 (draft 2020-12).- 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--kind json-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, { requireMeta? })takes the JSON text or the parsed value. It checks it againstschemas/catalog-v1.json, then runs the validation rules, and 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).operationFindings(previous, next)compares theoperationsof two catalogs by name and returns theoperation-risk-raisedandoperation-approval-removedfindings: ameta.riskthat rose, or a permission that no longer listsapprovals. A new or removed operation is not a finding.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 --kind json-schema come from one schema constant, and a test checks the committed catalogs against both the reader and Ajv.
Validation rules
After the schema passes, parseCatalog checks the references JSON Schema cannot express. It reports every broken rule at once, each issue with its path:
| Rule | Issue path |
|---|---|
A role's on is a declared scope name, resource, or tenant or team when scopes is absent | roles[i].on |
An operation's permission and results are declared permission keys | operations[i].permission, .results |
A grant's permission is a declared permission key | grants[i].permission |
| Every key a delegation or OAuth scope lists is a declared permission key | delegations[i].permissions[j], oauthScopes[i].permissions[j] |
permissions[].key, roles[].key, scopes[].name, plans[].key and operations[].name are unique | the second entry's key or name |
With requireMeta, every permission carries those meta fields (title, risk, effects) | permissions[i].meta.<field> |
requireMeta is off by default: collect writes meta from what the code declares, and an application without a hosted console needs none of it. A consumer that displays or approves on these fields, such as PermDock Cloud, passes parseCatalog(json, { requireMeta: ['title', 'risk', 'effects'] }). permdock cloud push --dry-run prints the same issues before an upload.
Markdown output
--kind 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/docs/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.
Side effects
What each command may change; side effects explains each one.
| Command | Side effects |
|---|---|
permdock catalog | files |
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.