Fastify
permdock/fastify registers the Fetch kernel as a Fastify plugin that decorates request.permdock and provides protect as a typed preHandler hook.
Status: planned Phase: 2
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. |
protect(permission, loadData?) | preHandler hook. Typed against the route's RouteGenericInterface, so request.params and request.body keep their types inside loadData (permix's Elysia and Fastify guards lost route typing). |
permdockHandler | Plugin mounting the AuthZEN-shaped decision endpoint: app.register(permdockHandler, { prefix: '/api/permdock' }). |
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
- The plugin's
onRequesthook callssubject(request)once and builds the instance. protectruns aspreHandler: load, validate untrusted input, decide, continue orreply.code(403).- Handlers call
request.permdock.assert(...); thrown PermDock errors reach the plugin'ssetErrorHandlerand become Problem Details. on('decision')events carryrequest.methodandrequest.routeOptions.url.
What it validates
- Untrusted
loadDataresults and bodies undervalidate: 'boundary'. Fastify's own JSON Schema validation runs first; PermDock validates only the resource shape used by the policy. - Decision-endpoint bodies against the AuthZEN
evaluationsschema. - Plugin registration: using
protecton a route in a scope where the plugin is not registered throws at startup.
How denials surface
403 application/problem+json for denied; 403 with type ending in /approval-required plus token; 400 with issues for boundary validation; 401 for anonymous subjects when a scheme is configured. See Problem Details.
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.
Related standards
Open questions
- Whether the plugin should also register the error handler or leave that to the app (some apps already own
setErrorHandler). - Whether
request.permdockDatashould exist or the loader result should be passed by re-running the loader inside the handler. - Type-provider interplay: confirm
protectkeeps inference with@fastify/type-provider-zodand TypeBox providers.
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.