PermDock
Adapters

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.

Status: planned Phase: 2

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

The 3.3 target is experimental and emits the pinned OpenAPI 3.3 Security Profile draft next to the stable x-permdock-securityProfile extension (OpenAPI 3.3, ADR 0025).

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 (release) adds the device authorization flow, oauth2MetadataUrl, deprecated on security schemes and security schemes referenced by URI, which is why 3.2 is the primary target (decision 0014).

PermDock is one stage of a pipeline it does not own: a producer writes the description, PermDock adds authorization, an applier merges it, and SDK generators, docs UIs and bridges consume the result. It composes with those tools through standard fields and an Overlay rather than wrapping any of them (ADR 0023, OpenAPI ecosystem).

Works with

StageToolsHow PermDock connects
Producers with an in-process hookhono-openapi, @hono/zod-openapi, @orpc/openapi, trpc-to-openapi, @elysia/openapi, @fastify/swagger, @nestjs/swaggerdescribe(), security(), spec() passed into the generator next to protect
Producers without a hooknext-openapi-gen (Next.js, TanStack Start, React Router, Remix, SvelteKit, Nuxt, Astro), TypeSpec, tsoa, ts-rest, Effect HttpApi, hand-written YAML or JSONoverlay() or permdock openapi emit --format overlay, applied by the producer or a CLI
Appliersnext-openapi-gen overlay.apply, Redocly CLI join --overlay, Bump.sh bump overlay, Speakeasy CLI, Zuplo CLI, overlays-js, openapi-formatConsume the Overlay; PermDock ships none
SDK generatorsHey API (@hey-api/openapi-ts), Orval, Kubb, openapi-typescript, OpenAPI Generator, Kiota, Fern, Speakeasy, Redocly generate-clientRead standard security; no PermDock code
Docs UIs and hostsScalar, Swagger UI, Redoc, Stoplight Elements, fumadocs-openapi, Zudoku; hosted: Mintlify, Bump.sh, Fern docs, Redocly Realm, GitBook, PostmanRead standard security; Scalar optionally renders the x-badges approval hint; PermDock never writes a host's own extensions
OpenAPI-to-MCP bridgesOrval and Kubb MCP output, Scalar, Speakeasy, Fern, Mintlify, Postman MCP Generator, Zuplo, Kong openapi2mcp, AWS AgentCore GatewayBind each generated tool's permission from x-permdock-permissions (MCP adapter)
LintersSpectral, Redocly lint, vacuumA PermDock ruleset file (Phase 2) checks the document invariants
Testing and diffSchemathesis ignored_auth, oasdiff, Prism, MicrocksRead security; turn PermDock's output into a runtime parity test and a breaking-change gate (CLI: openapi)
WorkflowsArazzo runners, permdock.simulateRead operationId and x-permdock-permissions from the applied description (Arazzo)

The full producer, applier and consumer matrix with notes per tool is on the OpenAPI ecosystem research page.

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: middleware-attached spec extension
const protectedProc = os.use(protect(permissions.post.delete)).use(oo.spec(spec(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 permission scope from listPermissions(permissions) listed under the configured flows, plus description from action metadata. Schemes can be referenced by URI in 3.2 ($ref to a shared security document) when scheme.ref is set.
  • security(permission | permission[]) returns the per-operation security array: [{ oauth: ['post:delete'] }]; multiple permissions produce one requirement object (AND) unless anyOf is passed (OR across objects).
  • describe(permission) returns security plus x-permdock-permissions: ['post.delete'] and, when the permission has conditions, x-permdock-conditions in the portable JSON format so clients can explain what a scope allows.
  • spec(permission) is the oRPC oo.spec callback form: it receives the operation object and returns it extended.
  • deprecated: true on an action's metadata marks the scope deprecated in 3.2 and emits x-oai-deprecated for 3.1.
  • securityProfile: 'fapi2' in the adapter options writes x-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' with securityProfile set additionally emits the pinned OpenAPI 3.3 Security Profile draft: securitySchemes() returns a second, type: profile scheme (profileScheme, default permdockFapi2) with profileMetadata.name: fapi-20-security-profile, supportedParametersSchema and servers from scheme.oauth2MetadataUrl; describe() and spec() are unchanged (operations keep security and the extension twin); securityProfileRequirements() returns the components.securityProfileRequirements map, one entry per distinct scope set, for generators that accept extra components; overlay() includes it. The root x-permdock-catalog.drafts records the pin. The target is experimental: the shape follows the pinned draft until 3.3.0 is released, then switches to the released construct (ADR 0025).
  • scheme.type: 'gnap' is a reserved value that throws today with a pointer to the GNAP page; no GNAP scheme is emitted until the OpenAPI Initiative defines one.

Registry and namespace

Every PermDock-specific field in a document is an OpenAPI extension under the x-permdock- prefix: x-permdock-permissions, x-permdock-conditions, x-permdock-securityProfile, x-permdock-approval, x-permdock-oauth2MetadataUrl and the root-level x-permdock-catalog. Registering the permdock namespace in the OAI Namespace Registry is a Phase 2 action item. 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, x-agent-trust for agent-authenticated apiKey schemes) 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

The adapter can describe its changes as an OpenAPI Overlay instead of returning fragments to be spliced into the document. overlay() returns an Overlay 1.1.0 document whose actions target operations by operationId and update them with security and x-permdock-* fields, plus one action for components.securitySchemes. 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, ADR 0025). The source document stays untouched and owned by the API team; the Overlay is reviewed and applied in the build. 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: [...] }

Recipe: 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 before writing the spec, and scaffolds Scalar. PermDock's contribution is the Overlay:

// 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 Scalar

Two rules keep the recipe honest. operationId is the join key: next-openapi-gen generates one per route, the Overlay targets it, and --check fails when it is missing. And there is one owner per field: on operations PermDock covers, do not also use next-openapi-gen's @auth JSDoc tag or authPresets; PermDock owns security, the producer owns paths and schemas. The same recipe applies to any producer that cannot call describe(), including hand-written descriptions. The Next.js adapter page repeats it in context.

Recipe: SDK generators and docs UIs

Nothing to wire. Point @hey-api/openapi-ts, Orval or Redocly generate-client at the applied description; the generated SDK attaches credentials from securitySchemes and the right scopes per operation. Scalar, Swagger UI and Redoc render security as they render any description. 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 } (name illustrative) 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. PermDock never writes x-scalar-* (registries).

Recipe: linters

A ruleset file for Spectral, Redocly and vacuum ships with the CLI in Phase 2. 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. Teams that already lint descriptions in CI get PermDock's invariants for free; permdock openapi emit --check stays the authoritative gate.

Request lifecycle

There is no request path: this adapter runs at document generation time and at import time.

Generation:

  1. The framework hook (describeRoute, createRoute, oo.spec, meta.openapi) receives the object returned by describe / security / spec next to the protect middleware for the same permission.
  2. The document generator collects operations; securitySchemes() supplies the component with all scopes.
  3. permdock openapi --check (CLI) compares the generated document against the routes registered with protect and 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):

  1. Operations are grouped by tags or path segments into resources; operationId or method and path derive action names (GET /posts/{id} becomes post.read, POST /posts becomes post.create).
  2. security scopes and x-permdock-permissions are read back into keys and scopes; request and response schemas become resource schemas for the chosen validator (--schema zod|valibot|arktype).
  3. A deterministic definePermissions() file with a // @generated header is written; it merges with hand-written definitions through mergePermissions like any feature file (larger apps).

What it validates

  • Every permission referenced in describe / security exists in the catalog (type error at compile time; runtime throw for strings from findPermission).
  • Scopes are unique across the catalog and match the resource:action convention; collisions after mergePermissions are reported.
  • 3.1 target: 3.2-only fields are moved to extensions so the document stays valid 3.1: deviceAuthorization to x-oai-deviceAuthorization (with x-oai-deviceAuthorizationUrl), deprecated on schemes to x-oai-deprecated, oauth2MetadataUrl to x-permdock-oauth2MetadataUrl (no x-oai-* extension is registered for it), and URI-referenced schemes are inlined.
  • Extension names: only registered x-oai-* names and x-permdock-* names appear in output; any other prefix is a bug.
  • 3.3 target: the type: profile scheme and securityProfileRequirements validate against a PermDock-maintained patch of the 3.2 JSON Schema keyed by the draft pin, since no official 3.3 schema exists; the x-permdock-securityProfile twin is present on every scheme and operation that carries the native construct; x-permdock-catalog.drafts names the pin.
  • Overlay 1.2: overlay({ version: '1.2' }) output validates against the schemas/v1.2-dev JSON Schema at the pinned commit; references carry only $ref, target and description, and components.actions keys 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 opaque scope 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 403 application/problem+json body produced by protect names the same permission key and scope that the document advertises, so a client can match a denial to a documented requirement.
  • Documented response 403 entries are added to each protected operation with the Problem Details schema (type, title, permission, denials, alternatives), including the .../approval-required type variant.
  • WWW-Authenticate step-up hints emitted by HTTP adapters use the same scope strings as securitySchemes.

Example app

No dedicated app. apps/examples/hono generates a 3.2 document with hono-openapi, apps/examples/orpc uses oo.spec, apps/examples/trpc uses trpc-to-openapi protect, apps/examples/next runs next-openapi-gen with overlay.apply and serves the result in Scalar, and apps/examples/monorepo runs permdock openapi import on a third-party document, merges the generated permissions, and generates a client with @hey-api/openapi-ts from the applied description.

Open questions

  • Whether describe should also emit x-permdock-conditions by default or only with an option, given conditions can leak model details to API consumers.
  • How to map several permissions on one route (AND versus OR) in security without inventing a vendor extension.
  • Decided by ADR 0023: the fragment API stays for producers with an in-process hook; the Overlay is the documented default for producers without one. Whether the fragment API becomes a compatibility layer is revisited only if generators start consuming Overlays directly.
  • Import heuristics for action names when operationId is absent and paths are irregular; a mapping file may be needed.
  • Whether to emit securitySchemes per document or as a shared URI-referenced document in monorepos with several APIs.
  • trpc-to-openapi supports only a boolean protect; per-scope requirements may need an upstream change or post-processing of the generated document.

On this page