PermDock
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

oRPC is contract-first and generates OpenAPI natively, which makes it the RPC framework where the OpenAPI 3.2 story is most direct. permdock/orpc mirrors the tRPC adapter: 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

// 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()),
  );
ExportRole
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).
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).
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.
permdockHandlerProcedure 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

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).

// 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

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.

// 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

A procedure behind protect(permission, load?, { items }) whose handler returns an event iterator is wrapped the same way as a tRPC subscription: 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 by hand.

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; 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.

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

permdock/orpc follows the shared adapter contract 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

  • 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

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 scenarios over RPCHandler with BatchHandlerPlugin on @hono/node-server and an RPCLink client with BatchLinkPlugin. Uploads are skipped: the mount takes JSON input.

Last updated on

On this page