PermDock
Adapters

Server kernel

permdock/server is the Fetch-first kernel every HTTP and RPC adapter wraps; it resolves the subject from a Request, scopes one PermDock per request, runs protect, emits Problem Details and exposes the OpenAPI hook contract.

Status: planned Phase: 1

Purpose

permdock/server implements the shared behaviour of every server adapter once, in terms of the Fetch API (Request, Response, Headers). Frameworks that already speak Fetch (Hono, Elysia, SvelteKit, Next.js Route Handlers, Bun, Deno, Cloudflare Workers) can use it directly. Frameworks with their own request types (Express, Fastify, Nest, Node http) wrap it with a few lines of typed glue. The kernel owns four things: subject resolution, request-scoped instance creation, protect semantics, and the denial-to-response mapping. Adapters add only naming, storage of the instance on the framework context, and OpenAPI hooks.

This is the design permix reached in a rejected PR (createRequestKernel) after each adapter had grown its own copy of the request-isolation workaround; see permix lessons.

API

import { createPermDock } from 'permdock/server'
import { policy } from './policy'

export const { permdock, protect, problem, openapi } = createPermDock(policy, {
  subject: async (request) => userFromSession(request.headers.get('cookie')),
  actor: async (request) => agentFrom(request),          // optional; Web Bot Auth fills this automatically when enabled
  webBotAuth: { directory: fetchSignatureAgentDirectory }, // optional RFC 9421 verification (Phase 4)
  problem: { base: 'https://api.example.com/problems' },  // Problem Details `type` prefix
})

// Plain Fetch handler
export default async function handler(request: Request): Promise<Response> {
  const guard = await protect(permissions.post.delete, (request) => loadPost(idFrom(request)))(request)
  if (!guard.ok) return guard.response                  // 403 application/problem+json
  const { permdock: instance, data: post } = guard
  await deletePost(post)
  return new Response(null, { status: 204 })
}
ExportRole
permdock(request)Resolves subject, actor and delegation once per Request (memoised in a WeakMap), builds the immutable PermDock, returns it. Never throws for anonymous callers; subject returning null yields an anonymous instance.
protect(permission, loadData?)Returns (request) => Promise<Guard>. Loads data when the permission is an instance action, validates it at the boundary, calls decide, and returns either ok: true with permdock, decision and data, or ok: false with a ready Response.
problem(decision, init?)Builds the RFC 9457 Response for a denied or approval-required decision. Adapters call it when they need to emit the body through their own response object.
openapiThe hook contract: openapi.security(permission) returns the per-operation security requirement and x-permdock-permissions extension; openapi.securitySchemes() returns the scheme with all scopes from listPermissions. Framework hooks (describeRoute, createRoute, oo.spec, trpc-to-openapi) call these.
PermDockDeniedError, PermDockApprovalRequiredErrorRe-exported so adapters can catch what assert throws inside handlers and route it through problem.

Typed generics

createPermDock in the kernel is generic over the policy (for permission and subject types) and over an optional Ctx type that framework adapters bind to their context. Adapter factories forward those generics so c.get('permdock') in Hono, req.permdock in Express and ctx.permdock in tRPC are typed PermDock for that policy without casts. permix's setupMiddleware returned an untyped MiddlewareHandler, which its users hit in issue 27; here the middleware type is derived, not any.

protect semantics

  • Collection action (permissions.post.create): no loadData; decide runs on the subject alone.
  • Instance action with loadData: the loader runs before the handler; a null or undefined result maps to 404 (configurable to 403 to avoid existence leaks); the loaded value is validated against the resource schema only if loadData is marked untrusted ({ boundary: true }) or the value came from the request body.
  • approval-required: protect fails closed with a 403 whose type ends in /approval-required and whose body carries token. The handler never runs.
  • Every outcome is emitted through on('decision') with the request method and path attached so otel and audit sinks see HTTP context.

Verified material

The subject resolver is the kernel's trust boundary. createPermDock(policy, { subject: async (request) => ... }) receives the raw Request, and whatever the resolver returns is taken as verified: the kernel does not re-verify sessions or tokens, so the resolver must only return material something has already checked (Authentication and PermDock).

Resolver returnsKernel does
null or undefinedBuilds an anonymous instance; no roles, no grants
A principal object ({ id, roles, ... })Uses it as principal; actor and delegation come from the actor option, Web Bot Auth or nothing
A full Subject (principal, actor?, delegation?, expiresAt?) as returned by subjectFromJwt, subjectFromSupabase, subjectFromClerk, subjectFromBetterAuthUses all parts as given; binding on principal or actor is passed through to the instance and audit events unchanged
A thrown errorCaught; treated as null and reported through on('auth') with the error, so a broken resolver denies rather than crashes the route

Three consequences:

  • A resolver that decodes a JWT without verifying it, or reads a X-User-Id header, has turned unverified input into a principal, and nothing downstream can detect it. Use a subjectFrom* function or the framework's session API as the last step of the resolver.
  • MCP servers do not go through this resolver: permdock/mcp receives authInfo from the SDK's bearer middleware and hands the principal mapping the same verified object (MCP adapter). The shape of the trust boundary is the same; only the carrier differs.
  • binding is pass-through. The kernel records { method, thumbprint } but does not check proof-of-possession; that is done by the resolver (verifyDpopProof in permdock/jwt) or by the TLS terminator for mTLS. An adapter that can forward the client certificate thumbprint passes it to the resolver as the second argument.

Request lifecycle

  1. The framework hands the kernel a Request (or the adapter converts its request object).
  2. subject(request) runs once. If webBotAuth is enabled and the request carries a Signature and Signature-Agent, the kernel verifies the RFC 9421 signature against the agent's directory and fills actor with the verified key id; delegation comes from bearer-token scopes or authorization_details when an adapter or provider extracts them.
  3. createPermDock(policy, subject, { actor, delegation }) builds the instance, memoised for this Request.
  4. Handlers call can, decide, assert, filter, where, or run behind protect.
  5. A denied or approval-required decision becomes a Response via problem; PermDockDeniedError thrown by assert inside a handler is caught by the adapter's error hook and routed through the same function.

What it validates

  • Data supplied by the request (body, params fed into an untrusted loadData) against the resource schema under validate: 'boundary'. Trusted rows from a loader are not re-validated.
  • Bearer tokens are not validated by the kernel; the subject resolver or a provider does that. The kernel only reads scopes and authorization_details that a resolver returns.
  • Web Bot Auth signatures when enabled: created/expires window, covered components, key lookup through the Signature-Agent directory.
  • Permission references are typed; the kernel never parses permission strings from a request.

How denials surface

403 with Content-Type: application/problem+json:

{
  "type": "https://api.example.com/problems/permission-denied",
  "title": "Permission denied",
  "status": 403,
  "permission": "post.delete",
  "denials": [{ "role": "member", "reason": "post.authorId must equal subject.id" }],
  "alternatives": ["post.read", "post.update"]
}

approval-required uses type .../approval-required and adds token and reason. Anonymous subjects hitting a permission that any role could grant receive 401 with WWW-Authenticate when the adapter knows the scheme, otherwise 403. Bodies are stable and documented in errors so clients and models can parse them.

Example app

No dedicated example; the kernel is exercised by every HTTP example (apps/examples/hono first) and by the SvelteKit route in apps/examples/svelte. Kernel unit tests live next to the entry and cover anonymous subjects, loadData returning null, boundary validation failures (PermDockValidationError to 400), and approval-required responses.

Open questions

  • Whether permdock(request) should also accept a framework context object directly (so adapters can memoise on their own request type) or always require a Request.
  • Default for a null loader result: 404 hides existence, 403 is more honest; the plan does not decide.
  • How approval-required should surface over plain HTTP beyond the 403 body (listed in the roadmap open questions): a Retry-After hint, a Location to an approval page, or nothing.
  • Whether Web Bot Auth verification belongs in the kernel (Phase 4) or in a separate permdock/web-bot-auth entry that the kernel calls.
  • Whether the kernel should expose a streaming-friendly protect variant for Server-Sent Events handlers.

On this page