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 })
}| Export | Role |
|---|---|
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. |
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, oo.spec, trpc-to-openapi) call these. |
PermDockDeniedError, PermDockApprovalRequiredError | Re-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): noloadData;decideruns on the subject alone. - Instance action with
loadData: the loader runs before the handler; anullorundefinedresult maps to404(configurable to403to avoid existence leaks); the loaded value is validated against the resource schema only ifloadDatais marked untrusted ({ boundary: true }) or the value came from the request body. approval-required:protectfails closed with a403whosetypeends in/approval-requiredand whose body carriestoken. 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 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 | 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-Idheader, has turned unverified input into a principal, and nothing downstream can detect it. Use asubjectFrom*function or the framework's session API as the last step of the resolver. - MCP servers do not go through this resolver:
permdock/mcpreceivesauthInfofrom 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. bindingis pass-through. The kernel records{ method, thumbprint }but does not check proof-of-possession; that is done by the resolver (verifyDpopProofinpermdock/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
- The framework hands the kernel a
Request(or the adapter converts its request object). subject(request)runs once. IfwebBotAuthis enabled and the request carries aSignatureandSignature-Agent, the kernel verifies the RFC 9421 signature against the agent's directory and fillsactorwith the verified key id;delegationcomes from bearer-token scopes orauthorization_detailswhen an adapter or provider extracts them.createPermDock(policy, subject, { actor, delegation })builds the instance, memoised for thisRequest.- Handlers call
can,decide,assert,filter,where, or run behindprotect. - A
deniedorapproval-requireddecision becomes aResponseviaproblem;PermDockDeniedErrorthrown byassertinside 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 undervalidate: 'boundary'. Trusted rows from a loader are not re-validated. - Bearer tokens are not validated by the kernel; the
subjectresolver or a provider does that. The kernel only reads scopes andauthorization_detailsthat a resolver returns. - Web Bot Auth signatures when enabled:
created/expireswindow, covered components, key lookup through theSignature-Agentdirectory. - 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.
Related standards
- Problem Details (RFC 9457).
- OpenAPI 3.2: the
securityandsecuritySchemesshapes the hook contract emits. - Web Bot Auth (RFC 9421 HTTP Message Signatures).
- OAuth agent delegation: where
delegationvalues come from.
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 aRequest. - Default for a
nullloader result:404hides existence,403is more honest; the plan does not decide. - How
approval-requiredshould surface over plain HTTP beyond the 403 body (listed in the roadmap open questions): aRetry-Afterhint, aLocationto an approval page, or nothing. - Whether Web Bot Auth verification belongs in the kernel (Phase 4) or in a separate
permdock/web-bot-authentry that the kernel calls. - Whether the kernel should expose a streaming-friendly
protectvariant for Server-Sent Events handlers.
Next.js
permdock/next wires one explicit server factory into Server Components, Server Actions, Route Handlers and the client, built for Next.js 16.3 Cache Components and Instant Navigations.
Hono
permdock/hono wraps the Fetch kernel as a Hono middleware and a protect route guard, with OpenAPI 3.2 security emitted through hono-openapi or @hono/zod-openapi.