PermDock
Adapters

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();
  },
);
ExportRole
permdockPlugin (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.
permdockHandlerPlugin 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.
openapiKernel 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 onRequest hook resolves the subject once; protect runs as a preHandler and stores the loaded row on request.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 setErrorHandler keeps 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 Request from the body Fastify already parsed. Multipart bodies stay with @fastify/multipart: request.file() works in a handler behind protect.
  • Using protect on a route in a scope where the plugin is not registered throws at startup.
  • Decision events carry request.method and request.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.

Last updated on

On this page