PermDock
Adapters

tRPC

permdock/trpc adds a request-scoped PermDock to tRPC context and a protect middleware that loads the resource from procedure input, with a trpc-to-openapi hook for OpenAPI security.

Purpose

tRPC procedures have no HTTP surface of their own, so the server kernel is used for subject resolution and decision mapping while denials become TRPCError values. permdock/trpc gives you ctx.permdock on every procedure and protect(permission, load) as a middleware whose loader receives the parsed input, so the same typed reference guards a mutation and documents it. When trpc-to-openapi exposes procedures as REST, the adapter's hook emits OpenAPI 3.2 security for them.

API

// server/permdock.ts
import { initTRPC } from "@trpc/server";
import { createPermDock } from "permdock/trpc";
import { policy } from "./policy";
import { permissions } from "./permissions";

export const { permdock, protect, openapi } = createPermDock(policy, {
  subject: (opts) => opts.ctx.user ?? null,
});

const t = initTRPC.context<Context>().create();
export const router = t.router;
export const procedure = t.procedure.use(permdock()); // ctx.permdock is the request-scoped PermDock

// server/routers/posts.ts
export const postsRouter = router({
  list: procedure.query(async ({ ctx }) =>
    ctx.permdock.filter(permissions.post.read, await listPosts()),
  ),

  remove: procedure
    .input(z.object({ id: z.string() }))
    .use(protect(permissions.post.delete, ({ input }) => loadPost(input.id)))
    .meta(openapi.security(permissions.post.delete)) // trpc-to-openapi: { openapi: { protect: true, ... } }
    .mutation(async ({ ctx }) => deletePost(ctx.permdockData)),
});
ExportRole
permdock()Middleware. Calls subject(opts) once per request (memoised on ctx so nested routers share the instance) and extends ctx with permdock. Typed through tRPC's middleware context inference, no manual context augmentation.
protect(permission, load?, options?)Middleware placed after .input(...). The tenant option reads each procedure's own opts ((opts) => opts.input?.org), so procedures in one httpBatchLink batch decide in their own tenant; the subject is resolved once per HTTP request. load receives input, ctx; the result is validated at the boundary when the loader is untrusted and passed on as ctx.permdockData. Denials throw TRPCError with code FORBIDDEN and the Problem Details body as cause. protect(null, load?, { oauthScopes }) needs a principal and the OAuth scopes, but no permission.
openapi.security(permission)Returns the meta fragment for trpc-to-openapi: protect: true, securitySchemes scope names, and x-permdock-permissions. openapi.securitySchemes() feeds generateOpenApiDocument.
permdockHandlerProcedure (or standalone Fetch handler) implementing the AuthZEN-shaped decision endpoint for permdock/react clients that share the tRPC server.
request option(ctx) => Request | null | undefined. The Web Request behind a context, for adapters whose context holds it under another name; defaults to ctx.request or ctx.req. A throwing mapper is no request.

Subscriptions

A subscription procedure behind protect(permission, load?, { items }) opens a kernel Connection when its resolver returns an async iterable. Items the subscriber cannot read under items are dropped (for tracked() envelopes the row inside is checked), and when the connection aborts (a revoked session, an expired token, a re-denied opening permission) the iterator ends with a TRPCError: UNAUTHORIZED or FORBIDDEN, with the Problem Details on cause. The connection's first data read is the row protect already loaded; load runs again only when a revocation event revalidates the connection. Pass revocations to createPermDock to feed it. connection(opts, options?) opens a connection by hand for anything else.

onProjectEvent: t.procedure
  .input(z.object({ projectId: z.string() }))
  .use(
    protect(permissions.project.read, loadProject, {
      items: permissions.project.read,
    }),
  )
  .subscription(async function* ({ input, signal }) {
    for await (const event of projectEvents(input.projectId, signal))
      yield tracked(event.id, event);
  });

Request lifecycle

permdock/trpc follows the shared adapter contract with tRPC-specific steps:

  • protect runs after tRPC's input parser, so boundary validation sees parsed input; it hands the row on as ctx.permdockData. Placing protect before permdock() is a type error because ctx.permdock is absent from the inferred context.
  • A PermDock error thrown in a resolver (from ctx.permdock.assert(...)) comes back to permdock() or protect() as a failed result and is rethrown as the same TRPCError a guard denial produces (FORBIDDEN, BAD_REQUEST, UNAUTHORIZED, NOT_FOUND or TOO_MANY_REQUESTS from the Problem status). Pass errorFormatter to initTRPC.create so the client receives the Problem Details fields under data. Only Problem Details the adapter produced are merged; any other cause (a database error, a thrown object) never reaches the client.
  • on('decision') events carry the procedure path and type.
  • The decision endpoint exists in both shapes: permdockHandler as a procedure for a typed, batched tRPC client, and as the plain AuthZEN Fetch handler for clients outside tRPC. The peer range is @trpc/server >=11.

How denials surface

  • denied: TRPCError code FORBIDDEN, message from the first denial reason, cause carrying the Problem Details object (permission, denials, alternatives). Over trpc-to-openapi the HTTP response is 403 application/problem+json.
  • approval-required: TRPCError code FORBIDDEN with cause.type ending in /approval-required and cause.token.
  • Boundary validation failure: TRPCError code BAD_REQUEST with issues.
  • Anonymous subject: UNAUTHORIZED when the procedure requires one.

Why

  • trpc-to-openapi keeps protect: true. Its protect is a boolean: a protected procedure requires the document's security schemes, with no way to name scopes per procedure. Per-scope requirements would need a fork or a post-processing step on its output; instead openapi.security puts the permission keys in x-permdock-permissions, and permdock openapi emit over the generated document turns them into per-operation security with the exact scopes. One pass through the CLI gives the same result as the producers with a native per-operation hook.

Example app

apps/examples/trpc: tRPC v11 with the Fetch adapter, a posts router using permdock() and protect on posts.update (granted) and posts.publish (denied for member). Unit tests call procedures through createCaller and assert TRPCError codes and Problem Details cause bodies.

tests/integration/src/http/trpc.test.ts runs the testHttpAdapter scenarios over fetchRequestHandler on @hono/node-server with @trpc/client and httpBatchLink, one client per caller so same-caller calls share a batch. Uploads are skipped: the mount takes JSON input.

Last updated on

On this page