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)),
});| Export | Role |
|---|---|
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. |
permdockHandler | Procedure (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:
protectruns after tRPC's input parser, so boundary validation sees parsed input; it hands the row on asctx.permdockData. Placingprotectbeforepermdock()is a type error becausectx.permdockis absent from the inferred context.- A PermDock error thrown in a resolver (from
ctx.permdock.assert(...)) comes back topermdock()orprotect()as a failed result and is rethrown as the sameTRPCErrora guard denial produces (FORBIDDEN,BAD_REQUEST,UNAUTHORIZED,NOT_FOUNDorTOO_MANY_REQUESTSfrom the Problem status). PasserrorFormattertoinitTRPC.createso the client receives the Problem Details fields underdata. Only Problem Details the adapter produced are merged; any othercause(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:
permdockHandleras 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:TRPCErrorcodeFORBIDDEN, message from the first denial reason,causecarrying the Problem Details object (permission,denials,alternatives). Overtrpc-to-openapithe HTTP response is403 application/problem+json.approval-required:TRPCErrorcodeFORBIDDENwithcause.typeending in/approval-requiredandcause.token.- Boundary validation failure:
TRPCErrorcodeBAD_REQUESTwithissues. - Anonymous subject:
UNAUTHORIZEDwhen the procedure requires one.
Why
trpc-to-openapikeepsprotect: true. Itsprotectis 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; insteadopenapi.securityputs the permission keys inx-permdock-permissions, andpermdock openapi emitover the generated document turns them into per-operationsecuritywith 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.
Related standards
- OpenAPI 3.2 through
trpc-to-openapi. - Problem Details for the REST surface and the
causeshape. - AuthZEN for the decision endpoint.
Last updated on
Terminal (your own CLI)
permdock/terminal puts permission checks inside command-line tools you build with commander, citty, oclif, yargs, clack or Ink, with verified subjects from device flow, keychain, env or CI tokens, sysexits exit codes and Problem Details on --json.
oRPC
permdock/orpc adds a request-scoped PermDock to oRPC context, a protect middleware fed by procedure input, and an OpenAPI security fragment for oRPC 2 `openapi()` metadata.