# Server kernel

Source: https://permdock.com/docs/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 [#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](/docs/research/landscape)).

## API [#api]

```ts
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 });
}
```

| Export | Role |
| --- | --- |
| `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](#snapshot-loaders). |
| `snapshotHeaders(snapshot, { tags, min, max })`, `cacheLifeFor`, `snapshotTag` | `snapshotHeaders` 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](#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. |
| `openapi` | The 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`, `PermDockRevokedError` | Re-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 [#options]

| Option | Role |
| --- | --- |
| `subject`, `actor`, `webBotAuth` | As above. Each framework adapter's `actor` takes the same framework context as its `subject`. |
| `tenant` | A 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. |
| `InstanceOptions` | `memberships`, `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`, `customRoles` | `MembershipSource`, and a `RoleSource` or a `RoleSourceFactory` that builds one for each request's subject ([extension interfaces](/docs/concepts/extension-interfaces#membershipsource-and-rolesource)). |
| `store`, `sink` | `ApprovalStore` for the `PermDock-Approval` resume and `DecisionSink` for decision events. |
| `limits` | `LimitStore` for quota grants. Without it a quota grant denies with `limit-unavailable`. |
| `pdp` | `createPermDock` from [`permdock/pdp`](/docs/adapters/pdp). `protect` then decides delegated permissions through the provider; `can` on the instance stays synchronous and keeps denying them with `pdp-unavailable`. |
| `approval` | An `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. |
| `otel` | [OpenTelemetry](/docs/adapters/otel) spans, counters and the duration histogram. |
| `revocations` | A `RevocationFeed` ([extension interfaces](/docs/concepts/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](/docs/guides/extending#adapter-responses)). |
| `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](/docs/guides/extending#request-data)). A throw adds nothing and reports `on('error')`. |
| `wrap` | A `PermDockWrap` applied to each instance after `otel`. Build it with `wrapPermDock` so `tenant()`, `team()` and `derive()` stay wrapped. |
| `operations` | `permdock/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 [#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 [#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:

| Option | Role |
| --- | --- |
| `adapter` | The label on decision events, revocations and approval records. Defaults to `server`. |
| `wrap` | Replaces the public `wrap` with the composed wrapper the adapter applies: its `otel` first, then the application's `wrap`. Defaults to no wrapper. |

```ts
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`](/docs/adapters/testing) runs the shared adapter contract against the result.

### `protect` semantics [#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](/docs/concepts/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](/docs/standards/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`](/docs/adapters/mcp): the decision still needs the delegation to cover the permission, so the policy's [`oauthScopes`](/docs/security/delegation#coarse-oauth-scopes) 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`](/docs/adapters/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](/docs/concepts/decisions#explain) 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](/docs/concepts/wire-formats#decision-event) carries the permission, resource, subject, tenant and outcome, not the HTTP method or path; an [otel](/docs/adapters/otel) span or a `wrap` adds request context where it is needed.

### Verified material [#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](/docs/concepts/authentication)).

| Resolver returns | Kernel does |
| --- | --- |
| `null` or `undefined` | Builds 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`, `subjectFromApiKey` | Uses all parts as given; `binding` on principal or actor is passed through to the instance and audit events unchanged |
| A thrown error | Caught; 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](/docs/adapters/mcp)). 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](/docs/concepts/credentials)).
* `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 [#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`):

```tsx
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:

```ts
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`:

```ts
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 })`:

```ts
// 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 [#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`:

```ts
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();
```

| Member | Role |
| --- | --- |
| `permdock` | The current instance; replaced, never mutated, after a revalidation. |
| `signal` | Aborts 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 [#request-lifecycle]

The kernel implements the [shared adapter contract](/docs/adapters) 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](/docs/adapters/supabase) 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 [#how-denials-surface]

`denied` and `approval-required` become `403 application/problem+json` bodies built by `problem` ([Problem Details](/docs/standards/problem-details), [errors](/docs/concepts/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](/docs/adapters/approvals)).

## Example app [#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`](/docs/adapters/testing) scenarios against the kernel behind a plain Fetch handler on `@hono/node-server`.

## Related standards [#related-standards]

* [Problem Details](/docs/standards/problem-details) (RFC 9457).
* [OpenAPI](/docs/standards/openapi): the `security` and `securitySchemes` shapes the hook contract emits.
* [Web Bot Auth](/docs/standards/web-bot-auth) (RFC 9421 HTTP Message Signatures).
* [OAuth agent delegation](/docs/standards/oauth-agent-delegation): where `delegation` values come from.
