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 { middleware, protect, openapi } = createPermDock(policy, {
subject: ({ context }) => context.user ?? null,
});
export const base = os.$context<Context>().use(middleware()); // context.permdock is the request-scoped PermDock
// server/routers/posts.ts
export const remove = base
.meta(
orpcOpenapi({
method: "DELETE",
path: "/posts/{id}",
spec: openapi.spec(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 |
|---|---|
getPermDock(opts) | The PermDock for one procedure call from its middleware options, for a custom middleware placed before middleware(). It reuses the instance middleware() put on the context. |
middleware() | 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, data?, options?) | Middleware placed after .input(...). protect(null, data?, { 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, data?) | Same as protect. Pair it with openapi.spec(permission) in .meta(orpcOpenapi({ spec })) so the operation's security requirement and x-permdock-permissions extension are merged into the generated document. |
openapi.spec(permission) | The openapi.security(permission) fragment as the merge function spec takes, with mutable arrays so it type-checks against oRPC's OperationObject. |
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). |
operationsRouter(operations, routes, options?) | One procedure per defineOperations entry, guarded by the operation's permission and data loader (operations router). |
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. |
permdockHandler() | Returns the Fetch handler (request) => Promise<Response> 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 middleware() 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(middleware())
.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.
Operations router
operationsRouter(operations, routes, { meta? }) turns an OperationMap into an oRPC router keyed by operation name. Each procedure runs middleware(), the route's input schema, then protect(operation.permission, ({ input }) => operation.data(input)), so the permission and the row loader come from the operation and are not repeated per procedure.
// server/router.ts
import { openapi as orpcOpenapi } from "@orpc/openapi";
import { operations } from "./operations"; // defineOperations(permissions, { ... })
export const router = operationsRouter(
operations,
{
"post.publish": {
input: z.object({ id: z.string() }),
handler: ({ context }) => publishPost(context.permdockData),
},
"post.list": {
handler: ({ context }) =>
context.permdock.filter(permissions.post.read, listPosts()),
},
},
{
meta: (operation) => [
orpcOpenapi({
operationId: operation.name,
summary: operation.meta.title,
spec: openapi.spec(operation.permission),
}),
],
},
);operationsRouter throws at definition time when a route names no operation, an operation has no route, a route has no handler function, or an operation with data has no input schema; the last three are also type errors. meta(operation) returns oRPC meta plugins. permdock/orpc does not import @orpc/openapi, so the app passes openapi() from it; with openapi.spec, OpenAPIGenerator writes each operation's security and x-permdock-permissions.
Event iterators
A procedure behind protect(permission, data?, { 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.
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). Before better-supabase 0.8, bs.authed() checked roles only together with requireTenant or authorize, so a procedure guarded by roles alone admitted every signed-in caller: upgrade before relying on it. From 0.8, permission on bs.authed() is decided by PermDock's authorizer.
Request lifecycle
permdock/orpc follows the shared adapter contract with oRPC-specific steps:
protect(oropenapi.protect) runs after oRPC's input validation and hands the row on ascontext.permdockData.protectrequirespermdockin the inferred context, so the wrong order is a type error. Both guards stay exported:openapi.protectis the same guard for apps that generate OpenAPI and read it next toopenapi.security.- A PermDock error thrown in a handler propagates through
middleware()andprotect(), which rethrow it as the sameORPCErrora guard denial produces, with the Problem Details body asdata; the OpenAPI handler writes that body asapplication/problem+json. - When
OpenAPIGeneratorruns,openapi({ spec })metadata from each procedure carries thesecurityfragment fromopenapi.spec. - An
operationsRouterroute withoutinputpasses no input; an operation that loads a row must declare one, so itsdataloader never reads unvalidated input.
How denials surface
denied:ORPCErrorcodeFORBIDDEN, HTTP status403,datacarrying the Problem Details object. Through the OpenAPI handler the response body isapplication/problem+json.approval-required:FORBIDDENwithdata.typeending in/approval-requiredanddata.token.- Boundary validation failure:
BAD_REQUESTwithissues. - Anonymous subject:
UNAUTHORIZED.
Example app
apps/examples/orpc: oRPC with middleware() 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.
Related standards
- OpenAPI 3.2.
- Problem Details.
- AuthZEN for the decision endpoint.
Last updated on
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.
MCP
permdock/mcp guards MCP tools with typed permissions, scope step-up challenges, per-caller tool lists, boundary-validated arguments and model-readable refusals.