OpenAPI
permdock/openapi emits OpenAPI 3.2 security schemes, per-operation security and x-permdock-permissions from permission references, hooks into hono-openapi, @hono/zod-openapi, oRPC and trpc-to-openapi, hands an Overlay to next-openapi-gen and other producers, and imports OpenAPI documents into a catalog.
permdock/openapi works in both directions. Outbound, it derives securitySchemes, per-operation security and an x-permdock-permissions extension from the permission references attached to routes, targeting OpenAPI 3.2 with registered x-oai-* fallbacks (and x-permdock-oauth2MetadataUrl) for 3.1 documents, either in-process through a framework's generator hook or as an Overlay applied to a document another tool produced. Inbound, permdock openapi import reads an OpenAPI document and generates a definePermissions() file from its operations and scopes.
Purpose
No OpenAPI generator in the TypeScript ecosystem is fed by an authorization library; every one of them (@hono/zod-openapi, hono-openapi, @orpc/openapi, trpc-to-openapi, @elysia/openapi, @fastify/swagger, @nestjs/swagger, next-openapi-gen) expects security to be attached by hand per route. PermDock already knows the permission for each protected route (protect(permissions.post.delete, ...)), and each permission carries a scope. Emitting the security metadata from that knowledge keeps the document honest: a route cannot document a scope it does not enforce. OpenAPI 3.2 is the primary target because it adds the device authorization flow, oauth2MetadataUrl, deprecated on security schemes and security schemes referenced by URI (OpenAPI 3.2 and 3.3). The 3.3 target is experimental and emits the pinned Security Profile draft next to the stable x-permdock-securityProfile extension.
The pipeline
PermDock is one stage of a pipeline it does not own, and composes with every other stage through standard fields and an Overlay rather than wrapping any tool.
- Producer. A framework plugin or a route-file scanner turns handlers into a description. Producers own paths, parameters, schemas and
operationIds. - PermDock. Where there is an in-process hook, the fragments below are attached during generation. Otherwise
permdock openapi emit --format overlaywrites an Overlay that addssecuritySchemes, per-operationsecurityandx-permdock-*. - Applier. The project's existing Overlay applier merges it. PermDock ships none.
- Consumers. Docs UIs, SDK generators, OpenAPI-to-MCP bridges, Arazzo runners, gateways and
permdock openapi importread standardsecurity; the ones that opt in also readx-permdock-*.
operationId is the join key across all four stages: producers generate it, the Overlay targets it, Arazzo steps reference it, and --check fails when it is missing.
Works with
| Stage | Tools | How PermDock connects |
|---|---|---|
| Producers with an in-process hook | hono-openapi, @hono/zod-openapi, @orpc/openapi, trpc-to-openapi, @elysia/openapi, @fastify/swagger, @nestjs/swagger | describe(), security(), spec() passed into the generator next to protect |
| Producers without a hook | next-openapi-gen (Next.js, TanStack Start, React Router, Remix, SvelteKit, Nuxt, Astro, Hono, Express), TypeSpec, tsoa, ts-rest, Effect HttpApi, feTS, hand-written YAML or JSON | overlay() or permdock openapi emit --format overlay, applied by the producer or a CLI |
| Appliers | next-openapi-gen overlay.apply, Redocly CLI join --overlay, Bump.sh bump overlay and its GitHub Action, Speakeasy CLI, Zuplo CLI, overlays-js, openapi-format, oas-patch | Consume the Overlay |
| SDK generators | Hey API, Orval, Kubb, openapi-typescript, OpenAPI Generator, Kiota, Fern, Speakeasy, Redocly generate-client | Read standard security; no PermDock code |
| Docs UIs and hosts | Scalar, Swagger UI, Redoc, Stoplight Elements, RapiDoc, fumadocs-openapi, Zudoku; hosted: Mintlify, Bump.sh, Fern docs, Redocly Realm, Docusaurus OpenAPI, GitBook, Postman | Read standard security; Scalar optionally renders the x-badges approval hint; PermDock never writes a host's own extensions |
| OpenAPI-to-MCP bridges | Orval and Kubb MCP output, Scalar, Speakeasy, Fern, Mintlify, Postman MCP Generator, Zuplo, Kong openapi2mcp, AWS AgentCore Gateway | Bind each generated tool's permission from x-permdock-permissions (MCP adapter) |
| Linters | Spectral, Redocly lint, vacuum | A PermDock ruleset file checks the document invariants |
| Testing and diff | Schemathesis ignored_auth, oasdiff, Prism, Microcks | Read security; turn PermDock's output into a runtime parity test and a breaking-change gate (CLI: openapi) |
| Workflows | Arazzo runners, permdock.simulate | Read operationId and x-permdock-permissions from the applied description (Arazzo) |
Every tool here also has a row on the ecosystem index.
API
import { createPermDock } from 'permdock/openapi'
const { securitySchemes, security, describe, spec, overlay } = createPermDock(policy, {
scheme: {
name: 'oauth',
type: 'oauth2',
oauth2MetadataUrl: 'https://auth.example.com/.well-known/oauth-authorization-server',
flows: { authorizationCode: {}, deviceAuthorization: {} }, // scopes filled from the catalog
},
target: '3.2', // '3.1' | '3.2' | '3.3' ('3.1' emits the registered fallbacks; '3.3' is experimental and
// emits the pinned OpenAPI 3.3 Security Profile draft next to x-permdock-securityProfile)
securityProfile: 'fapi2', // optional; on '3.3' also emits the type: profile scheme
profileScheme: 'permdockFapi2', // optional; name of that scheme
})
// hono-openapi
app.delete('/posts/:id', describeRoute({ ...describe(permissions.post.delete) }), protect(permissions.post.delete, loadPost), handler)
// @hono/zod-openapi
createRoute({ method: 'delete', path: '/posts/{id}', security: security(permissions.post.delete), ... })
// oRPC: OpenAPI metadata on the procedure
const protectedProc = os
.meta(orpcOpenapi({ spec: spec(permissions.post.delete) }))
.use(protect(permissions.post.delete))
// trpc-to-openapi: `protect: true` plus x-permdock-permissions in meta
t.procedure.meta({ openapi: { method: 'DELETE', path: '/posts/{id}', protect: true, ...describe(permissions.post.delete) } })
// components.securitySchemes for the document
generateSpecs(app, { components: { securitySchemes: securitySchemes() } })securitySchemes()returns the scheme with every permissionscopefromlistPermissions(permissions)listed under the configured flows, plusdescriptionfrom action metadata. Schemes can be referenced by URI in 3.2 ($refto a shared security document) whenscheme.refis set.security(permission | permission[])returns the per-operationsecurityarray:[{ oauth: ['post:delete'] }]; multiple permissions produce one requirement object (AND) unlessanyOfis passed (OR across objects).describe(permission)returnssecurityplusx-permdock-permissions: ['post.delete']and, whenever the permission's allow grants have portable conditions,x-permdock-conditionskeyed by permission key (several grants join underor), so clients can explain what a scope allows. A permission whose grants need an approval addsx-permdock-approval: { "<key>": { "reason": "human" } }. The conditions are policy shape, not data; an API that must not publish them usessecurity()instead ofdescribe().spec(permission)is the fragment passed asspecto oRPC'sopenapi()metadata helper: it extends the generated operation object.scheme.deprecated: trueretires the scheme:deprecated: trueon 3.2 and 3.3,x-oai-deprecated: trueon 3.1. OpenAPI has no per-scope deprecation, so an action'smeta.deprecateddoes not reach the document.security()returns[](public) for a permission every subject is granted: an allow toanyone()with no condition, approval, limit or purpose, and no deny on it. With several permissions that takes all of them, or any one withanyOf.securityProfile: 'fapi2'writesx-permdock-securityProfile: "fapi2"on the scheme and on every described operation, so consumers know the resource server follows the FAPI 2.0 Security Profile (bearer or DPoP tokens in the header only, sender-constrained tokens verified). The CLI equivalent is--profile fapi2.target: '3.3'withsecurityProfileset additionally emits the pinned Security Profile draft:securitySchemes()returns a second,type: profilescheme (profileScheme, defaultpermdockFapi2) withprofileMetadata.name: fapi-20-security-profile,supportedParametersSchemaandserversfromscheme.oauth2MetadataUrl;describe()andspec()are unchanged (operations keepsecurityand the extension twin);securityProfileRequirements(scopeSets?)returns thecomponents.securityProfileRequirementsmap, one entry per distinct scope set (one per permission withoutscopeSets), each naming the profile scheme, the FAPI 2.0 client authentication methods and the grant types of the configured flows;overlay()includes it. The rootx-permdock-catalog.draftsrecords the pin. The shape follows the pinned draft until 3.3.0 is released, then switches to the released construct (OpenAPI 3.2 and 3.3).scheme.type: 'gnap'is a reserved value that throws with a pointer to the watch list; no GNAP scheme is emitted until the OpenAPI Initiative defines one.
Contract packages
securityFor and permissionsExtension take permission references only, so a contract package that imports the permissions but never the policy can write the security of each operation. A tests/bundle case asserts that the two helpers bundle without any policy, evaluation or permission-definition code.
// contract/posts.ts: imports permissions.ts, never policy.ts
import { permissionsExtension, securityFor } from "permdock/openapi";
import { permissions } from "./permissions";
export const security = {
"posts.delete": securityFor(permissions.post.delete),
// { security: [{ oauth2: ['post:delete'] }], 'x-permdock-permissions': ['post.delete'] }
"posts.review": securityFor(
[permissions.post.update, permissions.post.publish],
{ anyOf: true },
),
};
permissionsExtension(permissions.post.delete); // ['post.delete']| Option | Default | Effect |
|---|---|---|
scheme | 'oauth2' | The security scheme name. 'oauth2' matches the adapters' openapi.securitySchemes(). |
anyOf | false | One requirement per scope (any one suffices) instead of one requirement with every scope. |
problemDetails is the Standard Schema of the Problem Details body every adapter sends on a denial, with a JSON Schema for generators. oRPC contracts use it as the data of FORBIDDEN (typed errors).
securityFor(permission) returns the same object as the server adapters' openapi.security(permission). It differs from createPermDock(...).describe in one case: without the policy it cannot tell that a permission is public, so it never returns an empty security. It also omits x-permdock-conditions and x-permdock-approval, which need the grants. Write those on the server or with permdock openapi emit. An empty list still returns [{ oauth2: [] }], because an empty security array marks an operation public.
Registry and namespace
Every PermDock-specific field in a document is an OpenAPI extension under the x-permdock- prefix. PermDock registers the permdock namespace in the OAI Namespace Registry (the pull request is described on OpenAPI registries). When the OpenAPI Initiative has already registered an extension for the same purpose (x-oai-deprecated, x-oai-deviceAuthorization, x-oai-deviceAuthorizationUrl for 3.1 targets) the adapter emits the registered name instead of inventing one; it never coins an x-oai-* name. The full extension table and the rules are on OpenAPI registries.
Overlay output
overlay({ operations }) returns an Overlay 1.1.0 document whose actions target each listed operation by its operationId and update it with security and x-permdock-* fields, plus one action for components.securitySchemes. Each entry of operations is { operationId, permissions }; without operations, there is one action per permission, targeting an operation whose operationId is the permission key. The CLI reads the operations from the source document's x-permdock-permissions. overlay({ version: '1.2' }) returns the pinned Overlay 1.2 draft instead: the per-operation bodies become reusable actions under components.actions, one per distinct permission set, and each operation is a $ref reference with its own target; x-permdock-catalog.drafts.overlay names the pin (OpenAPI Overlay). The source document stays untouched and owned by the API team. permdock openapi emit --format overlay (with --overlay 1.2 for the draft) produces the same output from the CLI.
const doc = overlay({ extends: "./openapi.json" });
// { overlay: '1.1.0', info: {...}, extends: './openapi.json', actions: [...] }
const draft = overlay({ extends: "./openapi.json", version: "1.2" });
// { overlay: '1.2.0', info: {...}, extends: './openapi.json', components: { actions: {...} }, actions: [...] }Recipes
A producer without a hook (Next.js and next-openapi-gen)
Next.js route handlers have no built-in spec generator, so permdock/next has no in-process hook. next-openapi-gen scans the route files, generates operationIds, applies Overlay files inside the same generate run before writing the spec, and scaffolds Scalar:
// openapi-gen.config.ts
export default defineConfig({
openapi: "3.2.0",
overlay: { apply: ["./permdock.overlay.json"] },
});permdock openapi emit --doc public/openapi.json --format overlay --out permdock.overlay.json
pnpm exec openapi-gen generate # applies the Overlay, writes the spec, scaffolds ScalarThe same two commands work for every producer without a hook: TypeSpec (the Overlay applies to the emitter output, never the .tsp source), tsoa, ts-rest, Effect HttpApi, feTS and hand-written descriptions. Where the producer does not apply Overlays, use Redocly CLI, Bump.sh (at docs deploy time), Speakeasy, Zuplo (before gateway import) or a vendor-neutral applier. The Next.js adapter page repeats the recipe in context.
One owner per field
The one failure mode of composing is two tools writing security on the same operation: next-openapi-gen's @auth JSDoc tag and authPresets, hono-openapi's security option and hand-written security all compete with PermDock. On operations PermDock covers, PermDock owns security and the producer owns everything else. permdock openapi emit --check reports operations whose source already carries security that the Overlay would replace.
SDK generators and docs UIs
Nothing to wire. Point Hey API, Orval, Kubb, Redocly generate-client, OpenAPI Generator or Kiota at the applied description; the generated SDK attaches credentials from securitySchemes with the right scopes per operation. openapi-typescript does not project security into types, so there is nothing to do. A denial arrives as the documented 403 Problem Details body, which generated SDKs surface as the typed error.
Optional, off by default: docsHints: { badges: true } adds x-badges: [{ name: 'Approval required' }] to operations whose permission carries approval, so portals that render x-badges (Scalar does) show the hint without understanding PermDock. It is a rendering hint with no semantics and the only field PermDock writes outside x-permdock-* and the registered set. Scalar's plugin API binds custom components to Info, Tag and Schema objects rather than operations, and Hey API's plugin API iterates operations, so a first-party plugin for either is considered only if these recipes prove insufficient.
One declaration for REST and MCP
An API that serves the same operations as REST routes and as MCP tools declares each operation's permission once. operationPermissions(operations, { base? }) takes "METHOD /path/{param}" keys, the OpenAPI path templates, mapped to a permission or to { permission, operationId }, and answers forRequest(method, path) for the HTTP gate (a literal segment wins over a {param}; a HEAD request matches the GET entry of its path when no HEAD entry matches, because frameworks such as Next.js serve HEAD with the GET handler; an undeclared operation is undefined, which a gate denies) and forOperation(id) for permdock/mcp's permissionFor when tool names are operation ids. An entry may also set oauthScopes, the coarse OAuth scopes that reach the operation when two operations share a permission but need different scopes, or set only oauthScopes ({ oauthScopes: ['api:chat'] }) for a route that checks no permission and is guarded with protect(null), whose forRequest is undefined; oauthScopesForRequest(method, path) answers them for the REST gate (pass the object as operations to permdock/server, or the scopes as protect's oauthScopes in another adapter) and oauthScopesForOperation(id) for permdock/mcp's oauthScopesFor. operationPermissionsFromOpenApi(document, permissions, { base? }) builds the same object from the x-permdock-permissions a description already carries, keeping each operation that names exactly one known permission:
import { operationPermissions } from "permdock/openapi";
export const operations = operationPermissions(
{
"GET /customers": permissions.customer.list,
"GET /customers/{id}": {
permission: permissions.customer.read,
operationId: "get_customer",
},
"DELETE /customers/{id}": {
permission: permissions.customer.delete,
operationId: "delete_customer",
oauthScopes: ["api:write"],
},
},
{ base: "/api/v1" },
);
// the REST gate
const permission = operations.forRequest(
request.method,
new URL(request.url).pathname,
);
// the MCP server
protectServer(server, {
enforce: "procedure",
permissionFor: operations.forOperation,
oauthScopesFor: operations.oauthScopesForOperation,
});OpenAPI-to-MCP bridges
Bridges generate one MCP tool per operation. Without PermDock the generated tools have no permission binding, so permdock/mcp's list_tools filtering and approval-required parking never run. Read each operation's x-permdock-permissions from the applied description and bind it as the tool's permission. Standard security is not enough here because the bridge needs the permission key, not the scope.
Bridges carry their own exposure extensions (Mintlify x-mcp, Zuplo x-zuplo-route.mcp, Kong x-kong-mcp-tool-name, Speakeasy x-speakeasy-mcp), which PermDock never writes. A consumer-side overlay may derive them from PermDock's fields so the tools agree with the policy: for example x-speakeasy-mcp.scopes from each operation's permission.scope (Speakeasy's --scope flags are a deploy-time filter, while permdock/mcp stays the per-caller check), or Mintlify's x-mcp opt-in from the presence of x-permdock-permissions. AWS AgentCore Gateway applies Cedar policies whose context.toolName is the operationId: when the MCP server runs in your process protectServer still applies; when Amazon hosts the target, the Cedar policy is the enforcement point and PermDock contributes the applied security and operation ids.
Testing and diff
- Schemathesis
ignored_authsends each operation that declaressecuritywithout credentials and fails unless the server answers401or403, so it catches a route that lost itsprotectafter the description was published. - oasdiff treats a removed or weakened
securityrequirement as a breaking change; compare the previous and current applied descriptions in CI behindpermdock openapi --check. - Prism and Microcks mock servers answer
401for secured operations without credentials, so client tests exercise the denied path.
Linters
A ruleset file for Spectral, Redocly and vacuum ships with the CLI. It checks that every operation carrying x-permdock-permissions also carries security, that no x-oai-* name outside the registered three appears, and that an Overlay has no remove on security. permdock openapi emit --check stays the authoritative gate.
Adjacent formats
AsyncAPI has no emitter. Overlay targets are JSONPath and not bound to OpenAPI, so if one is needed it is the same Overlay engine pointed at an AsyncAPI document, not a new adapter. Arazzo runners and simulate({ arazzo, openapi }) must read the applied description so x-permdock-permissions is present.
Request lifecycle
There is no request path: this adapter runs at document generation time and at import time.
Generation:
- The framework hook (
describeRoute,createRoute, oRPCopenapi({ spec }),meta.openapi) receives the object returned bydescribe/security/specnext to theprotectmiddleware for the same permission. - The document generator collects operations;
securitySchemes()supplies the component with all scopes, inlined in each document rather than shared by URI reference, so every API in a monorepo carries its own scheme. permdock openapi --check(CLI) compares the generated document against the routes registered withprotectand fails when a protected route has no security entry or a documented scope has no enforcing route.
Import (permdock openapi import ./openapi.json --out src/permissions.generated.ts):
- Operations are grouped by
tagsor path segments into resources;operationIdor method and path derive action names (GET /posts/{id}becomespost.read,POST /postsbecomespost.create). securityscopes andx-permdock-permissionsare read back into keys and scopes; request and response schemas become resource schemas for the chosen validator (--schema zod|valibot|arktype).- A deterministic
definePermissions()file with a// @generatedheader is written; it merges with hand-written definitions throughmergePermissionslike any feature file (larger apps).
What it validates
- Every permission referenced in
describe/securityexists in the catalog (type error at compile time; runtime throw for strings fromfindPermission). - Scopes are unique across the catalog and match the
resource:actionconvention; collisions aftermergePermissionsare reported. - 3.1 target: 3.2-only fields are moved to extensions so the document stays valid 3.1:
deviceAuthorizationtox-oai-deviceAuthorization(withx-oai-deviceAuthorizationUrl),deprecatedon schemes tox-oai-deprecated,oauth2MetadataUrltox-permdock-oauth2MetadataUrl(nox-oai-*extension is registered for it), and URI-referenced schemes are inlined. - Extension names: only registered
x-oai-*names andx-permdock-*names appear in output; any other prefix is a bug. - 3.3 target: the
type: profilescheme andsecurityProfileRequirementsvalidate against a PermDock-maintained patch of the 3.2 JSON Schema keyed by the draft pin, since no official 3.3 schema exists; thex-permdock-securityProfiletwin is present on every scheme and operation that carries the native construct;x-permdock-catalog.draftsnames the pin. - Overlay 1.2:
overlay({ version: '1.2' })output validates against theschemas/v1.2-devJSON Schema at the pinned commit; references carry only$ref,targetanddescription, andcomponents.actionskeys are RFC 6901-escaped in the$ref. - Import: documents are validated against the OpenAPI 3.1 or 3.2 schema before generation; unknown security scheme types produce
opaquescope entries rather than failing.
How denials surface
The adapter emits metadata; enforcement and denials belong to the server adapters. What it contributes to the denial story:
- The
403application/problem+jsonbody produced byprotectnames the samepermissionkey andscopethat the document advertises, so a client can match a denial to a documented requirement. - Documented response
403entries are added to each protected operation with the Problem Details schema (type,title,permission,denials,alternatives), including the.../approval-requiredtype variant. WWW-Authenticatestep-up hints emitted by HTTP adapters use the same scope strings assecuritySchemes.
Example app
No dedicated app. apps/examples/hono generates a 3.2 document with hono-openapi, apps/examples/orpc uses oRPC openapi() metadata, apps/examples/trpc uses trpc-to-openapi protect, and apps/examples/next runs next-openapi-gen with overlay.apply and serves the result in Scalar. apps/examples/monorepo is the collect example: two feature packages, mergePermissions at the app root, and permdock collect --check.
Related standards
- OpenAPI 3.2 and 3.3: security scheme additions, the 3.1 fallback table and the pinned Security Profile draft.
- OpenAPI registries: the
x-permdock-*namespace and registered names. - OpenAPI Overlay: the
--format overlayoutput and its threat model. - FAPI 2.0: what
securityProfile: 'fapi2'declares. - Standard Schema: request and response schemas via Standard JSON Schema.
- Problem Details: documented
403body. - CLI: openapi:
permdock openapiemit, import,--target,--formatand--check.
Last updated on
SCIM
permdock/scim is an RFC 7644 receiver for Users and Groups provisioned by Okta, Entra ID or Google Workspace; it writes to a DirectoryStore you own and exposes the synced groups as a MembershipSource, so deprovisioning and group-to-role changes reach decisions without a token refresh and without the Cloud on the decision path.
OpenTelemetry
permdock/otel records a span and a counter per permission check with decision attributes, behind a structural logger interface, with @opentelemetry/api as an optional dependency that is never required.