# oRPC

Source: https://permdock.com/docs/adapters/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.

Requires: `@orpc/server` 2.x (beta)

## Purpose [#purpose]

oRPC is contract-first and generates OpenAPI natively, which makes it the RPC framework where the [OpenAPI 3.2](/docs/standards/openapi) story is most direct. `permdock/orpc` mirrors the [tRPC adapter](/docs/adapters/trpc): `ctx.permdock` on every procedure, `protect(permission, load)` as a middleware after input validation, `ORPCError` denials carrying Problem Details, and `openapi.security(permission)` so the operation's `security` requirement can be attached with oRPC 2's `openapi()` metadata helper.

## API [#api]

```ts
// server/permdock.ts
import { os } from "@orpc/server";
import { openapi as orpcOpenapi } from "@orpc/openapi";
import { createPermDock } from "permdock/orpc";
import { policy } from "./policy";
import { permissions } from "./permissions";

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

export const base = os.$context<Context>().use(permdock()); // context.permdock is the request-scoped PermDock

// server/routers/posts.ts
export const remove = base
  .meta(
    orpcOpenapi({
      method: "DELETE",
      path: "/posts/{id}",
      spec: openapi.security(permissions.post.delete),
    }),
  )
  .input(z.object({ id: z.string() }))
  .use(protect(permissions.post.delete, ({ input }) => loadPost(input.id)))
  .handler(async ({ context }) => deletePost(context.permdockData));

export const list = base
  .meta(orpcOpenapi({ method: "GET", path: "/posts" }))
  .handler(async ({ context }) =>
    context.permdock.filter(permissions.post.read, await listPosts()),
  );
```

| Export | Role |
| --- | --- |
| `permdock()` | Middleware. Resolves the subject once per request (memoised on the request context) and adds `permdock` to the context with oRPC's context inference. Applied a second time on the same request, it reuses `context.permdock` only when this factory built that instance for the same tenant, because oRPC 2 does not dedupe middleware; any other value on `context.permdock` is replaced. |
| `protect(permission, load?, options?)` | Middleware placed after `.input(...)`. `protect(null, load?, { oauthScopes })` needs a principal and the OAuth scopes, but no permission. The `tenant` option reads each procedure's own opts (`(opts) => opts.input?.org`), so procedures in one `BatchLinkPlugin` batch decide in their own tenant. Loads and boundary-validates the resource, decides, continues with `context.permdockData` or throws `ORPCError` with code `FORBIDDEN`, through the procedure's declared constructor when it has one ([typed errors](#typed-errors)). |
| `openapi.protect(permission, load?)` | Same as `protect`. Pair it with `openapi.security(permission)` in `.meta(orpcOpenapi({ spec }))` so the operation's `security` requirement and `x-permdock-permissions` extension are merged into the generated document. |
| `openapi.securitySchemes()` | Document-level `securitySchemes` (scopes from `listPermissions`, `oauth2MetadataUrl`, `deviceAuthorization`, `deprecated`) to pass to `OpenAPIGenerator`. Targets 3.2; 3.1 output uses the registered `x-oai-*` fallbacks and `x-permdock-oauth2MetadataUrl` (see [OpenAPI registries](/docs/standards/openapi-registry)). |
| `permissionOf(procedure)` | The permission of the procedure's first `protect`, or `undefined`. It reads the procedure definition without deciding, for [MCP tools that call the procedure](/docs/adapters/mcp#tools-that-call-a-protected-procedure). |
| `permdockHandler` | Procedure or Fetch handler for the AuthZEN-shaped decision endpoint. |
| `request` option | `(context) => Request \| null \| undefined`. The Web `Request` behind a context; defaults to `context.request` or `context.req`. |

### Contract-first [#contract-first]

`protect` and `permdock()` attach to procedures from `implement(contract)` as they do to `os` procedures, including procedures whose contract declares an `output`. The contract package imports `securityFor` from `permdock/openapi` for each operation's `security`, without the policy ([contract packages](/docs/adapters/openapi#contract-packages)).

```ts
// contract.ts: permissions only
export const contract = { posts: { update: oc.input(byId).output(post) } };
export const security = {
  "posts.update": securityFor(permissions.post.update),
};

// server.ts
const os = implement(contract).$context<Context>();
export const router = os.router({
  posts: {
    update: os
      .use(permdock())
      .posts.update.use(
        protect(permissions.post.update, ({ input }) => loadPost(input.id)),
      )
      .handler(({ context }) => context.permdockData),
  },
});
```

### Typed errors [#typed-errors]

Declare `FORBIDDEN` in the contract with `problemDetails` from `permdock/openapi` as its `data` schema. `protect` then throws through the procedure's own `errors.FORBIDDEN` constructor, so clients get a defined error whose `data` is typed as `ProblemDetails`, and the OpenAPI generator documents the 403 body. Without the declaration, `protect` throws a plain `ORPCError` with the same `data`. `UNAUTHORIZED`, `BAD_REQUEST` and the other codes `protect` uses work the same way when the contract declares them.

```ts
// contract.ts
import { problemDetails } from "permdock/openapi";

const guarded = oc.errors({ FORBIDDEN: { status: 403, data: problemDetails } });
export const contract = { posts: { update: guarded.input(byId).output(post) } };

// client
const [error] = await safe(client.posts.update({ id }));
if (isDefinedError(error) && error.code === "FORBIDDEN") {
  error.data.permission; // 'post.update'
}
```

`problemDetails` is a Standard Schema that also implements Standard JSON Schema (`draft-2020-12`, `draft-07`, `openapi-3.0`). It requires `type`, `title`, `status` (400 to 599) and `detail`, and passes extension members through. It imports nothing, so a contract package can use it.

### Event iterators [#event-iterators]

A procedure behind `protect(permission, load?, { items })` whose handler returns an event iterator is wrapped the same way as a [tRPC subscription](/docs/adapters/trpc#subscriptions): items the subscriber cannot read are dropped, `load` runs again only on revalidation, and an abort ends the iterator with an `ORPCError` (`UNAUTHORIZED` or `FORBIDDEN`) whose `data` is the Problem Details body. Pass `revocations` to `createPermDock`; `connection(opts, options?)` opens a [`Connection`](/docs/adapters/server-kernel#connections) by hand.

### With better-supabase [#with-better-supabase]

better-supabase's `createOrpc` puts the verified `AuthState` on `context.auth` through `bs.middleware()`, which also adds `context.db`. Map it with [`subjectFromBetterSupabase`](/docs/adapters/better-supabase); pass its `apiKeys` option when API keys reach the router. Export one builder that takes a permission and keeps `bs.middleware()` inside it: a procedure built any other way has no `context.db`, and one built with `procedure()` does not compile without a permission. Serve the router with `bs.fetchHandler`.

```ts title="src/router.ts"
import { os } from "@orpc/server";
import { createOrpc, type OrpcRequestContext } from "better-supabase/orpc";
import type { AuthState } from "better-supabase/server";
import type { Permission } from "permdock";
import { createPermDock } from "permdock/orpc";
import { subjectFromBetterSupabase } from "permdock/better-supabase";

export const bs = createOrpc(betterSupabase);

const authed = os.$context<OrpcRequestContext>().use(bs.middleware());
const { protect } = createPermDock<
  OrpcRequestContext & { readonly auth: AuthState }
>(policy, {
  subject: ({ context }) =>
    subjectFromBetterSupabase(context.auth, { anonymousSignIns: "deny" }),
});

const procedure = (permission: Permission) => authed.use(protect(permission));

export const router = {
  customers: {
    list: procedure(permissions.customers.read).handler(({ context }) =>
      bs.unwrap(context.db.customers.findMany({ select: ["id", "name"] })),
    ),
  },
};
```

`protect` throws `FORBIDDEN` with PermDock's Problem Details as `data`, the same shape `bs.unwrap` gives a `DbError`. `@orpc/server` must satisfy better-supabase's peer range (2.0.0-beta.40 or later).

## Request lifecycle [#request-lifecycle]

`permdock/orpc` follows the [shared adapter contract](/docs/adapters) with oRPC-specific steps:

* `protect` (or `openapi.protect`) runs after oRPC's input validation and hands the row on as `context.permdockData`. `protect` requires `permdock` in the inferred context, so the wrong order is a type error. Both guards stay exported: `openapi.protect` is the same guard for apps that generate OpenAPI and read it next to `openapi.security`.
* A PermDock error thrown in a handler propagates through `permdock()` and `protect()`, which rethrow it as the same `ORPCError` a guard denial produces, with the Problem Details body as `data`; the OpenAPI handler writes that body as `application/problem+json`.
* When `OpenAPIGenerator` runs, `openapi({ spec })` metadata from each procedure carries the `security` fragment from `openapi.security`.

## How denials surface [#how-denials-surface]

* `denied`: `ORPCError` code `FORBIDDEN`, HTTP status `403`, `data` carrying the Problem Details object. Through the OpenAPI handler the response body is `application/problem+json`.
* `approval-required`: `FORBIDDEN` with `data.type` ending in `/approval-required` and `data.token`.
* Boundary validation failure: `BAD_REQUEST` with `issues`.
* Anonymous subject: `UNAUTHORIZED`.

## Example app [#example-app]

`apps/examples/orpc`: oRPC with `permdock()` and `protect` on `posts.update` (granted) and `posts.publish` (denied for `member`). Unit tests call procedures through `call()` and assert `ORPCError` codes and Problem Details `data`.

`tests/integration/src/http/orpc.test.ts` runs the [`testHttpAdapter`](/docs/adapters/testing) scenarios over `RPCHandler` with `BatchHandlerPlugin` on `@hono/node-server` and an `RPCLink` client with `BatchLinkPlugin`. Uploads are skipped: the mount takes JSON input.

## Related standards [#related-standards]

* [OpenAPI 3.2](/docs/standards/openapi).
* [Problem Details](/docs/standards/problem-details).
* [AuthZEN](/docs/standards/authzen) for the decision endpoint.
