Fastify
permdock/fastify registers the Fetch kernel as a Fastify plugin that decorates request.permdock and provides protect as a typed preHandler hook.
Purpose
Fastify's plugin system, request decorators and typed route generics map cleanly onto the server kernel. permdock/fastify registers a plugin that resolves the subject in an onRequest hook and decorates request.permdock; protect is a preHandler that loads data, decides and replies with Problem Details on denial. Fastify already fails closed after reply.send() in a hook, so denial never falls through to the handler.
API
import Fastify from "fastify";
import { createPermDock } from "permdock/fastify";
import { policy } from "./policy";
import { permissions } from "./permissions";
export const { permdock, protect } = createPermDock(policy, {
subject: (request) => request.user ?? null,
});
const app = Fastify();
await app.register(permdock); // request.permdock is the request-scoped PermDock
app.delete<{ Params: { id: string } }>(
"/posts/:id",
{
preHandler: protect(permissions.post.delete, (request) =>
loadPost(request.params.id),
),
schema: { security: openapi.security(permissions.post.delete).security }, // @fastify/swagger
},
async (request, reply) => {
await deletePost(request.permdockData);
return reply.code(204).send();
},
);| Export | Role |
|---|---|
permdock | Plugin (wrapped with fastify-plugin so decorators are visible to the parent scope). Adds request.permdock and registers an error handler mapping PermDock errors to Problem Details. Any other error goes to the error handler that was in place before, so an app's own setErrorHandler keeps working. |
protect(permission, loadData?, options?) | preHandler hook. The tenant option ((request) => request.params.org) is resolved for each protect. Generic over the route, schema and type provider (protect<Route, Schema, Provider>): route generics infer from the preHandler slot, so request.params and request.body keep their types inside loadData (permix's Elysia and Fastify guards lost route typing). With a zod or TypeBox type provider, pass the provider explicitly or read the body in the handler; see Why. |
permdockHandler | Plugin mounting the AuthZEN-shaped decision endpoint: app.register(permdockHandler, { prefix: '/api/permdock' }). |
withPermDock(handler) | Wraps a route handler registered after permdock or protect and types request.permdock with the policy's roles, plans and permissions. Generic over the route, like protect. The adapter does not augment FastifyRequest, for the reason the Express adapter gives. |
openapi | Kernel hook contract; feeds @fastify/swagger route schema.security and the document-level securitySchemes. |
Encapsulation
Fastify plugins are encapsulated by default. permdock is wrapped with fastify-plugin so the decorator and error handler are visible to the registering scope and its children; registering it once on the root instance covers every route. Apps that want different policies per prefix register the plugin from a second createPermDock result inside a child scope, and request.permdock is typed per scope through Fastify's declaration merging on the plugin's generic rather than a global FastifyRequest augmentation.
await app.register(
async (admin) => {
await admin.register(adminPermdock); // a second factory with a stricter policy
admin.get(
"/audit",
{ preHandler: adminProtect(permissions.audit.read) },
listAudit,
);
},
{ prefix: "/admin" },
);Request lifecycle
permdock/fastify follows the shared adapter contract through the server kernel. What differs on Fastify:
- The plugin's
onRequesthook resolves the subject once;protectruns as apreHandlerand stores the loaded row onrequest.permdockData, so the handler does not run the loader twice. - The plugin registers the error handler itself and chains to the one that was in place before, so thrown PermDock errors become Problem Details and an app's own
setErrorHandlerkeeps handling everything else. - Fastify's own JSON Schema validation runs first; PermDock validates only the resource shape the policy uses.
- The decision endpoint builds its Web
Requestfrom the body Fastify already parsed. Multipart bodies stay with@fastify/multipart:request.file()works in a handler behindprotect. - Using
protecton a route in a scope where the plugin is not registered throws at startup. - Decision events carry
request.methodandrequest.routeOptions.url.
Denials are the kernel's Problem Details responses.
Why
protect takes the route, schema and type provider as generics instead of fixing them to Fastify's defaults, so the handler it returns fits any typed preHandler slot. TypeScript infers route generics (app.get<{ Params }>) into loadData from that slot. A body derived from a type provider's schema does not flow into an inline preHandler: protect(...): the route shorthand infers its own schema generic from the same options object and accepts one handler or an array, and TypeScript does not carry the provider back through both into a nested generic call. The handler still sees the typed body. Rather than a Fastify-specific wrapper, protect<RouteGenericInterface, FastifySchema, ZodTypeProvider>(...) names the provider explicitly, and a loader that only needs request.params.id needs nothing at all. Both patterns are type-tested.
Example app
apps/examples/fastify: posts API with the plugin, protect on CRUD routes, @fastify/swagger emitting securitySchemes and per-route security, the decision endpoint, and app.inject() tests asserting exact denial bodies.
tests/integration/src/http/fastify.test.ts runs the testHttpAdapter scenarios with @fastify/multipart and a prefixed admin plugin.
Related standards
Last updated on
Express
permdock/express wraps the Fetch kernel as Express middleware, exposing req.permdock and a protect guard, with Problem Details errors routed through Express error handling.
Elysia
permdock/elysia derives a request-scoped PermDock into Elysia's context and provides protect as a typed beforeHandle hook that keeps validated route types.