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.

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.

permix adapters each copy request isolation onto the framework context; PermDock does it once in the kernel (landscape).

API

import {
  createPermDock,
  discoverViaSignatureAgent,
  verifyWebBotAuth,
} from "permdock/server";
import { policy } from "./policy";

export const { permdock, protect, connection, 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: (request) =>
      verifyWebBotAuth(request, {
        // optional RFC 9421 verification
        keys: discoverViaSignatureAgent({ allow: ["agents.example.com"] }),
        required: false,
      }),
    tenant: (request) => orgFromPath(request), // optional; resolved again on every protect
    limits: memoryLimitStore(), // optional LimitStore for quota grants
  });

// 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 for the request's tenant, returns it. One instance is cached per (Request, tenant) pair, so a global middleware that runs before routing and a later tenant-scoped protect never share an instance. Never throws for anonymous callers; subject returning null yields an anonymous instance. A request that claims a Web Bot Auth signature and fails verification throws InvalidSignatureError with a ready Problem Details response.
protect(permission, loadData?, options?)Returns (request) => Promise<Guard>. The tenant option is resolved for each call, so two routes of one request may decide in different tenants. The loader's result is validated unless options.trusted: true (ProtectOptions) marks it as a row the server loaded. 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.
getSnapshot(request, { tenant, include, tenants })The JSON snapshot for a framework loader, built from the instance permdock(request) caches, so a loader and a later protect on the same Request resolve the subject once. tenant overrides the tenant option. See Snapshot loaders.
snapshotHeaders(snapshot, { tags, min, max }), cacheLifeFor, snapshotTagsnapshotHeaders returns Cache-Control: private, max-age=<seconds> (from cacheLifeFor), Vary: Authorization, Cookie, an ETag over everything but issuedAt, and Cache-Tag with snapshotTag(sub) and tags. permdock/next exports the same cacheLifeFor and snapshotTag.
connection(request, options?)Opens a long-lived Connection for a stream or socket; see Connections.
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, oRPC openapi({ spec }), trpc-to-openapi) call these.
PermDockDeniedError, PermDockApprovalRequiredError, PermDockValidationError, PermDockRevokedErrorRe-exported so applications can recognise what assert throws inside handlers.
problemFromError(error, options?)Maps a thrown PermDock error (assert, boundary validation, an ended connection, a failed Web Bot Auth signature) to its Problem Details Response, with the status and headers protect sends for the same decision; credentials (whether the request carried an Authorization header) picks the 401 challenge. Returns undefined for any other error. Framework adapters call it from their error hook; a plain Fetch handler, permdock/node or a @supabase/middleware pipeline calls it in its own catch.

Options

OptionRole
subject, actor, webBotAuthAs above. Each framework adapter's actor takes the same framework context as its subject.
tenantA tenant id or (request) => string | undefined. Adapters pass their framework context instead of the Request. A throwing resolver is no tenant, never a default one.
InstanceOptionsmemberships, relations, entitlements, customRoles, policies, approvalPolicies, sink and limits, passed unchanged to core createPermDock for every instance. Every adapter's options type extends InstanceOptions from permdock.
memberships, customRolesMembershipSource, and a RoleSource or a RoleSourceFactory that builds one for each request's subject (extension interfaces).
store, sinkApprovalStore for the PermDock-Approval resume and DecisionSink for decision events.
limitsLimitStore for quota grants. Without it a quota grant denies with limit-unavailable.
pdpcreatePermDock from permdock/pdp. protect then decides delegated permissions through the provider; can on the instance stays synchronous and keeps denying them with pdp-unavailable.
approvalAn ApprovalHint ({ at?, hint? }) added as the approval member of every approval-required Problem Details: where to approve and what to tell the caller. The terminal adapter takes the same option.
otelOpenTelemetry spans, counters and the duration histogram.
revocationsA RevocationFeed (extension interfaces) that ends or revalidates open connections. Never a decision input.
onDenied({ decision, problem, request }) => ProblemDetails | Response | undefined, called for every refusal: unauthenticated, missing OAuth scope, a loader that threw, and each denied or approval-required decision. Returned Problem Details keep the default headers; a Response replaces the answer. A status below 300, a throw or an invalid return keeps the default and reports on('error'). It cannot grant (extend PermDock).
context(request) => RequestContext | undefined, plain JSON merged into subject.context under the policy's context; framework adapters pass their context instead of the Request. Server-derived values only; it is visible in the snapshot (request data). A throw adds nothing and reports on('error').
wrapA PermDockWrap applied to each instance after otel. Build it with wrapPermDock so tenant(), team() and derive() stay wrapped.
operationspermdock/server only: the result of operationPermissions from permdock/openapi (anything with oauthScopesForRequest(method, path)). protect reads the OAuth scopes the matching operation declares as the route's oauthScopes, unless the call passes its own.

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.

Build an adapter

createServerKernel(policy, options) is the kernel every in-tree HTTP and RPC adapter builds on; an out-of-tree adapter uses it the same way. It returns a ServerKernel: the members of createPermDock, each taking an optional TenantScope ({ tenant }) as its last argument. The adapter resolves its own tenant option against its framework context with tenantScope(option, context) and passes the result, so the kernel never reads the framework context. ServerKernelOptions adds one option to ServerPermDockOptions and narrows another:

OptionRole
adapterThe label on decision events, revocations and approval records. Defaults to server.
wrapReplaces the public wrap with the composed wrapper the adapter applies: its otel first, then the application's wrap. Defaults to no wrapper.
import { createServerKernel, tenantScope } from "permdock/server";

const kernel = createServerKernel(policy, {
  subject: (request) => userFrom(request),
  adapter: "my-framework",
});

app.use(async (ctx) => {
  const scope = await tenantScope(options.tenant, ctx);
  const guard = await kernel.protect(permissions.post.read)(ctx.request, scope);
  if (!guard.ok) return guard.response;
});

testHttpAdapter in permdock/testing runs the shared adapter contract against the result.

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 is a 404 .../not-found problem, and so is a denial on a resource with disclosure: 'hide' (permissions); the loaded value is validated against the resource schema unless protect is called with { trusted: true }. The decision endpoint always treats resource.properties as untrusted and validates them.
  • Denial status: 401 for an anonymous caller or a step-up (with acr_values and max_age), 429 with Retry-After and the RateLimit fields when every denial is limit, 503 for limit-unavailable, 403 otherwise (Problem Details).
  • approval-required: protect fails closed with a 403 whose type ends in /approval-required and whose body carries token. The handler never runs.
  • OAuth scopes per route: protect(permission, loadData, { oauthScopes: ['api:read'] }), or the scopes the matching operation declares under operations, narrow which coarse scopes reach the route. When the subject carries a token delegation (an OAuth client's scope), a token that holds none of them is refused before the loader runs with a 403 and WWW-Authenticate: Bearer error="insufficient_scope", scope="api:read", naming the first; a subject without a delegation, such as a first-party session, is not gated. They only narrow, as in permdock/mcp: the decision still needs the delegation to cover the permission, so the policy's oauthScopes lists the permission under each coarse scope that may reach it. A later delegation denial names the route's first scope in its challenge too. An empty list throws.
  • A route without a permission, such as a chat endpoint or setting the caller's active organization: protect(null, loadData?, { oauthScopes: ['api:chat'] }), or protect(null) with operations declaring { oauthScopes } for the operation. The guard checks no permission and has no decision: an anonymous caller gets a 401, a delegated token that holds none of the scopes the same 403 insufficient_scope challenge, and a first-party session passes. protect(null) with no scopes in its options and no operations throws when it is built, and a request no operation declares scopes for throws when it runs. The Hono, Express, Fastify, Elysia, Node, Nest (Protect(null, …)), tRPC and oRPC adapters take null the same way, and the Supabase middleware takes withPermDock({ oauthScopes }) without protect.
  • A loader that throws fails closed: the error is reported through on('error') and the route answers 403 with reason validation and the detail <key> failed closed: the row could not be loaded, as permdock/mcp does. The handler never runs.
  • Per-route decide options: protect(permission, loadData, { trusted, oauthScopes, field, explain }). field checks one schema field of the row, as decide(…, { field }) does, and explain adds the trace to the decision the guard returns.
  • A failed guard is { ok: false, response, decision }, with decision absent when the route checks no permission or the loader found no row, so an adapter that builds its own answer can read the outcome.
  • Every outcome is emitted through on('decision') like any other decision. The decision event carries the permission, resource, subject, tenant and outcome, not the HTTP method or path; an otel span or a wrap adds request context where it is needed.

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, subjectFromBetterAuth, subjectFromApiKeyUses 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.
  • API keys resolve through subjectFromApiKey(options), exported here next to its helpers (apiKeyVerifier, memoryCredentials, generateApiKey, hashApiKey, parseApiKey). It is itself a SubjectResolver, so a resolver that reads a pdk_ bearer calls it and returns the subject; a service key's membership comes from the credential, so skip the memberships source for it (API keys).
  • binding is pass-through. The kernel records the cnf object (jkt or x5t#S256) 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.

Snapshot loaders

getSnapshot(request) returns the snapshot a client provider (permdock/react, permdock/vue, permdock/svelte, permdock/solid) takes. Send it with snapshotHeaders(snapshot) when the framework lets a loader set headers. The private Cache-Control keeps a shared cache from storing it, and its max-age never reaches past expiresAt. Call invalidate() or refresh() on the client after a role change.

React Router 7 and 8, in app/root.tsx (apps/examples/react-router):

import { snapshotHeaders } from "permdock/server";
import { PermDockProvider } from "permdock/react";
import { data, Outlet, useLoaderData } from "react-router";
import { getSnapshot } from "~/permdock.server";

export async function loader({ request }: Route.LoaderArgs) {
  const snapshot = await getSnapshot(request);
  return data({ snapshot }, { headers: snapshotHeaders(snapshot) });
}

export function headers({ loaderHeaders }: Route.HeadersArgs) {
  return loaderHeaders;
}

export default function App() {
  const { snapshot } = useLoaderData<typeof loader>();
  return (
    <PermDockProvider snapshot={snapshot} endpoint="/api/permdock">
      <Outlet />
    </PermDockProvider>
  );
}

TanStack Start, with a server function the root route's loader calls:

import { createServerFn } from "@tanstack/react-start";
import { getRequest, setResponseHeaders } from "@tanstack/react-start/server";
import { snapshotHeaders } from "permdock/server";
import { getSnapshot } from "./permdock.server";

export const loadSnapshot = createServerFn().handler(async () => {
  const snapshot = await getSnapshot(getRequest());
  setResponseHeaders(new Headers(snapshotHeaders(snapshot)));
  return snapshot;
});

SvelteKit, in src/routes/+layout.server.ts; the layout passes data.snapshot to setPermDock:

import { snapshotHeaders } from "permdock/server";
import { getSnapshot } from "$lib/server/permdock";

export async function load({ request, params, setHeaders }) {
  const snapshot = await getSnapshot(request, { tenant: params.org });
  const { "Cache-Control": cacheControl, Vary: vary } =
    snapshotHeaders(snapshot);
  setHeaders({ "Cache-Control": cacheControl, Vary: vary });
  return { snapshot };
}

Nuxt, with a server route that useFetch reads before app.use(permdockPlugin, { snapshot }):

// server/api/permdock/snapshot.get.ts
import { snapshotHeaders } from "permdock/server";
import { getSnapshot } from "~~/server/permdock";

export default defineEventHandler(async (event) => {
  const snapshot = await getSnapshot(toWebRequest(event));
  setResponseHeaders(event, snapshotHeaders(snapshot));
  return snapshot;
});

Connections

A WebSocket, an SSE stream or a subscription lives longer than one decision. connection(request, options?) resolves the subject from the request that opened it and returns a frozen Connection:

const guard = await protect(permissions.project.read, loadProject)(request);
if (!guard.ok) return guard.response;
const conn = await connection(request, {
  permission: permissions.project.read,
  data: guard.data,
});

for await (const event of projectEvents(conn.signal)) {
  for (const row of conn.filter(permissions.project.read, [event])) send(row);
}
// conn.signal.reason is a PermDockRevokedError: send its toProblemDetails(), then close
conn.close();
MemberRole
permdockThe current instance; replaced, never mutated, after a revalidation.
signalAborts when the connection must end. signal.reason is a PermDockRevokedError with code session-revoked, expired, denied (the opening permission was re-denied; the Decision is on the error) or subject-changed.
check(permission, data?, { trusted? })Per-message decision; never throws. Message data is validated first unless trusted: true. After the abort or close() it returns denied with no-grant and detail: 'connection-revoked'.
filter(permission, items)Drops outbound items the subscriber cannot read; [] after the abort or close().
settled()Resolves once every revalidation queued so far has finished. Framework adapters await it before each streamed item, so an item after a revocation event is checked against the rebuilt instance.
close()Stops the timers and the feed subscription. Call it when the transport closes.

ConnectionOptions are permission and data (a value or a loader) for the permission that opened the connection, re-checked after every revalidation, and revalidate (milliseconds, off by default) for a periodic revalidation. The connection aborts with expired at the subject's expiresAt, and revalidates at the earliest membership expiry and on a changed event from revocations: subject(request) runs again on the original request, and a different or missing principal aborts with subject-changed. A session-revoked event aborts without revalidating.

Open a connection only after protect succeeded, so the refusal at open is the usual 401, 403 or 404 response. Framework adapters wrap the transport: Hono (socket, sse), tRPC and oRPC (async iterables returned behind protect), Elysia (connection(ws)) and Nest (connection(client, req)).

Request lifecycle

The kernel implements the shared adapter contract for every HTTP adapter. What it adds:

  • permdock(request) always takes a Fetch Request. Adapters with their own request type convert it first (toRequest in permdock/node) and pass their framework context only to the tenant resolver, so memoisation has one key everywhere.
  • When webBotAuth is set and the request carries Signature and Signature-Agent, the kernel verifies the RFC 9421 signature against the agent's directory (the created / expires window, covered components and key lookup) and fills actor with the verified key id.
  • Bearer tokens are not validated here; the subject resolver or a provider does that, and the kernel only reads the scopes and authorization_details the resolver returns.
  • PermDockDeniedError thrown by assert inside a handler is caught by the adapter's error hook and routed through problemFromError.

How denials surface

denied and approval-required become 403 application/problem+json bodies built by problem (Problem Details, errors); boundary validation failures are 400 with issues. Anonymous subjects hitting a permission that any role could grant receive 401 with WWW-Authenticate when the adapter knows the scheme, otherwise 403. The approval-required body with its token is the whole signal over plain HTTP: the kernel adds no Retry-After (a human reviewer has no predictable latency) and no Location (the approval UI belongs to the application or the approvals inbox).

Example app

apps/examples/react-router calls getSnapshot and snapshotHeaders from its root loader and serves the decision endpoint from a resource route. The rest of the kernel is exercised by every HTTP example (apps/examples/hono first) and by the Bun, Deno and workerd apps in tests/runtimes. 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. tests/integration/src/http/kernel.test.ts runs the testHttpAdapter scenarios against the kernel behind a plain Fetch handler on @hono/node-server.

Last updated on

On this page