# tRPC

Source: https://permdock.com/docs/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 [#purpose]

tRPC procedures have no HTTP surface of their own, so the [server kernel](/docs/adapters/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 [#api]

```ts
// 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 [#subscriptions]

A subscription procedure behind `protect(permission, load?, { items })` opens a kernel [`Connection`](/docs/adapters/server-kernel#connections) 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.

```ts
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 [#request-lifecycle]

`permdock/trpc` follows the [shared adapter contract](/docs/adapters) 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 [#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 [#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 [#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`](/docs/adapters/testing) 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 [#related-standards]

* [OpenAPI 3.2](/docs/standards/openapi) through `trpc-to-openapi`.
* [Problem Details](/docs/standards/problem-details) for the REST surface and the `cause` shape.
* [AuthZEN](/docs/standards/authzen) for the decision endpoint.
