Extend PermDock
Attach typed app data to permissions, resources, roles, plans, grants and memberships, add request data, declare app obligations, set UI defaults and replace adapter responses, without a plugin system and without changing what PermDock decides.
PermDock is extended through options on the functions you already call. Each need below has one extension point, and none of them can turn a denial into a grant.
| Need | Extension point |
|---|---|
| Your own fields on a permission, resource, role, plan | meta.x, typed by a schema on definePermissions, defineRoles or definePlans (app data) |
| Your own fields on a grant | allow(p, { name, meta }), typed by definePolicy(…, { x: { grant } }) |
| Your own fields on a membership or a custom role | Membership.x and CustomRole.meta.x from your sources, typed by definePolicy and defineRoles |
| A value from the request that a condition reads | The adapter's context hook (request data) |
| A rule PermDock cannot express | A closure grant, sqlFunction, a DecisionProvider or one of the interfaces |
| Something your code must do when a grant applies | App obligations, allow(p, { obligations }) (steering) |
| The same fallback, skeleton or wording on every page | Provider defaults and messages (UI defaults) |
| Your own error body, status or refusal text | The adapter's onDenied hook (adapter responses) |
| Logging, caching or metrics around every check | The adapter's wrap option and wrapPermDock (wrapping an instance) |
App data
Every definition takes an x field for data your application owns: a risk level, an owning team, a ticket id, a department. PermDock checks that it is plain JSON, freezes it, carries it to decisions, events, snapshots and the catalog, and never reads it.
import { z } from "zod";
const permissions = definePermissions(
{
invoice: resource(Invoice, {
actions: { pay: { title: "Pay invoice", x: { risk: "high" } }, read: {} },
meta: { title: "Invoice", x: { owner: "billing" } },
}),
},
{
x: {
permission: z.object({ risk: z.enum(["low", "high"]) }),
resource: z.object({ owner: z.string() }),
},
},
);
const roles = defineRoles(
{ clerk: { meta: { title: "Clerk", x: { tier: 1 } } } },
{ x: z.object({ tier: z.number() }) },
);
export const policy = definePolicy(
{ permissions, roles },
{
roles: [
role(roles.clerk, [
allow(permissions.invoice.pay, {
name: "clerk-pays",
meta: { description: "Clerks pay invoices", x: { ticket: "FIN-1" } },
}),
]),
],
principal: (user) => user,
x: {
grant: z.object({ ticket: z.string() }),
membership: z.object({ department: z.string() }),
},
},
);Where x lives | Schema | Invalid data | Read it from |
|---|---|---|---|
Permission leaf meta.x | definePermissions(…, { x: { permission } }) | Throws at definition | permissions.invoice.pay.meta.x, the catalog, snapshots |
Resource meta.x | definePermissions(…, { x: { resource } }) | Throws at definition | getResource(permissions, "invoice")?.meta, the catalog |
Role and plan meta.x | defineRoles(…, { x }), definePlans(…, { x }) | Throws at definition | roles.clerk.meta.x, the snapshot's vocabulary, the catalog |
Grant meta.x | definePolicy(…, { x: { grant } }) | Throws at definePolicy | decision.matched.meta, decision events, snapshot and catalog grants |
Membership.x | definePolicy(…, { x: { membership } }) | Dropped with an on('auth') event of reason schema | permdock.memberships(), the snapshot's principal.memberships |
Custom role meta.x | The role tree's defineRoles(…, { x }) | Dropped; the role stays | permdock.assignableRoles() |
Data you write in code throws when it is invalid, because that is an author error. Data a MembershipSource or RoleSource returns is dropped when invalid, because a request should never fail on a bad row: the x goes, the membership or role stays. Each schema must validate synchronously. With a schema, the field takes its output type; without one, it is AppData, a plain JSON object.
All of it is visible to whoever holds a snapshot: grant meta, membership x and every leaf's meta ship to the client. Never put a secret in x. Grant meta stays out of the policy fingerprint, so editing a description invalidates no decision or approval token. The Supabase sources read Membership.x from a jsonb column (fromTable({ columns: { x } }), fromJunction({ x })) and the token hook never writes it into claims (tenancy).
Request data
A condition can read context.<key>. The policy's own context function loads values once per subject; an adapter's context hook adds values from the request itself:
import { createPermDock } from "permdock/hono";
const { protect } = createPermDock(policy, {
subject: (c) => c.get("user"),
context: (c) => ({ region: c.get("geo").region }),
});| Adapter | Hook |
|---|---|
permdock/server and every HTTP adapter on it | context(request), or the framework context (c, req) |
permdock/supabase middleware | context(ctx, request) |
permdock/mcp | context(authInfo) |
permdock/ai-sdk | context(aiSdkContext) |
The hook returns a plain JSON object, merged into subject.context under the policy's context: on a clash, the policy's key wins. A hook that throws, or returns something other than a JSON object, adds no request context and reports through on('error'). It cannot set the subject, memberships, tenant or actor.
Return only values the server derived: a verified session field, a geo lookup your edge did, a flag the server evaluated. A header, query parameter, request body or tool argument is client input, and copying it into context lets the client choose what a condition sees (threat model). The merged context also appears in the snapshot's subject.context, so it never holds a secret. A context.* reference cannot compile to RLS; permdock rls generate refuses it.
Custom logic
Logic PermDock does not model goes in code you own, behind a fixed contract:
- A closure grant,
allow(p, (row, ctx) => …), runs any synchronous check in process; it is non-portable, so snapshots send it to the decision endpoint (policies). sqlFunctionnames a database function with a portable twin, for a rule that must hold in RLS too (conditions).- A
DecisionProviderdelegates chosen permissions to a remote PDP, OpenFGA or SpiceDB (PDP). - The extension interfaces cover subjects, memberships, roles, relations, stores and sinks, each with a conformance runner.
Steering
allow(p, { obligations }) attaches follow-ups your code owes when that grant applies. Each becomes { kind: 'app', name, detail? } on the granted decision:
allow(permissions.report.export, {
obligations: ["watermark", { name: "mfa-reprompt", detail: { maxAge: 300 } }],
});The snapshot carries them, so a client decision owes the same obligations as the server's. Decision events, OCSF and OpenTelemetry carry their names. Obligations lists every kind.
To steer on a denial, read decision.permission: the key that was checked, which findPermission resolves to the leaf and its meta.
UI defaults
PermDockProvider (and the Vue plugin, Svelte context setter and Solid provider) takes defaults for the pending, fallback and approval slots every <Protected> leaves out, and messages for useDescribe(). <Protected approval> renders an approval-required decision separately from a denial. Provider defaults has the props and the order a slot resolves in.
Adapter responses
The HTTP adapters take onDenied, called after a refusal with the decision, the Problem Details PermDock built and the request:
import { createPermDock } from "permdock/server";
const { protect } = createPermDock(policy, {
subject: (request) => sessionUser(request),
onDenied: ({ problem, decision }) => ({
...problem,
code: decision.outcome === "denied" ? "FORBIDDEN" : "NEEDS_APPROVAL",
}),
});- Return Problem Details with extra members to keep the status and headers, a
Responseto replace the answer, orundefinedfor the default. - It runs for an unauthenticated subject, a missing OAuth scope, a loader that threw and every denied or approval-required decision.
- A status below 300, a throw or an invalid return keeps the default response and reports through
on('error'). The handler behind the guard never runs. - A failed guard returns
{ ok: false, response, decision }, so a framework that builds its own answer can read the decision.
On Next.js the factory's onDenied also runs before requireAccess interrupts, unless the call passes its own (Next.js). On MCP and the AI SDK, onDenied receives { decision, permission, text } and may return replacement text only; isError, structuredContent and the approval shape stay as PermDock built them (MCP, AI SDK).
Wrapping an instance
wrap on the HTTP, MCP and AI SDK adapters receives each request's instance after otel and returns the one handlers see. Build it with wrapPermDock(permdock, overrides), which replaces the members overrides returns and applies it again to every instance tenant(), team() and derive() return:
import { wrapPermDock } from "permdock";
const { protect } = createPermDock(policy, {
subject: (request) => sessionUser(request),
wrap: (permdock) =>
wrapPermDock(permdock, (inner) => ({
// `can` is overloaded; the cast restores the overloads.
can: ((...args: Parameters<typeof inner.can>) => {
metrics.increment("permdock.can", { permission: args[0].key });
return inner.can(...args);
}) as typeof inner.can,
})),
});withOtel is built on the same helper. A wrapper changes what your code calls; the decision engine inside is untouched, so a wrapper that returns true from can lies to your code without changing decide, the decision log or RLS.
Why
- Options, not a plugin system. A plugin registry is module-level mutable state, and PermDock instances are immutable and request-scoped. Every extension point here is an option on a function you already call, scoped to the policy or adapter you pass it to, and visible in its type.
- PermDock never reads
x. App data that fed evaluation would need a portable form for snapshots, RLS and ORM filters, which turns it into a condition operator. Keepingxout of evaluation lets it be any JSON your app needs and lets you change it without moving a decision. A value a decision should depend on belongs in a condition, through the principal,contextor a relation. - One schema per kind. The
subjectFrom*helpers already take a Standard Schema, so the same shape types app data with nodeclare moduleaugmentation, and the schema runs at the boundary where the data enters. - App obligations share one
kind. A newkindper need would break every exhaustiveswitchoverObligation. One namespaced variant keeps the list closed and leaves the names to you. - Hooks cannot grant.
onDeniedruns only after a refusal and is checked for a refusal status;contextcannot set who the subject is. A hook that throws falls back to the default, so a bug in extension code fails closed.
Last updated on
Wire formats
The JSON shapes PermDock reads and writes, permission leaves, conditions, snapshots, memberships and custom roles, AuthZEN messages, the catalog, Decisions and Problem Details, with an example of each.
Next.js Cache Components
How a multi-org SaaS keeps permission UI in the prefetched App Shell with Next.js 16.3 Cache Components, Partial Prefetching and instant navigation, how fresh each layer is after a role or plan change, and how Supabase claims plug in.