PermDock
Guides

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.

NeedExtension point
Your own fields on a permission, resource, role, planmeta.x, typed by a schema on definePermissions, defineRoles or definePlans (app data)
Your own fields on a grantallow(p, { name, meta }), typed by definePolicy(…, { x: { grant } })
Your own fields on a membership or a custom roleMembership.x and CustomRole.meta.x from your sources, typed by definePolicy and defineRoles
A value from the request that a condition readsThe adapter's context hook (request data)
A rule PermDock cannot expressA closure grant, sqlFunction, a DecisionProvider or one of the interfaces
Something your code must do when a grant appliesApp obligations, allow(p, { obligations }) (steering)
The same fallback, skeleton or wording on every pageProvider defaults and messages (UI defaults)
Your own error body, status or refusal textThe adapter's onDenied hook (adapter responses)
Logging, caching or metrics around every checkThe 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 livesSchemaInvalid dataRead it from
Permission leaf meta.xdefinePermissions(…, { x: { permission } })Throws at definitionpermissions.invoice.pay.meta.x, the catalog, snapshots
Resource meta.xdefinePermissions(…, { x: { resource } })Throws at definitiongetResource(permissions, "invoice")?.meta, the catalog
Role and plan meta.xdefineRoles(…, { x }), definePlans(…, { x })Throws at definitionroles.clerk.meta.x, the snapshot's vocabulary, the catalog
Grant meta.xdefinePolicy(…, { x: { grant } })Throws at definePolicydecision.matched.meta, decision events, snapshot and catalog grants
Membership.xdefinePolicy(…, { x: { membership } })Dropped with an on('auth') event of reason schemapermdock.memberships(), the snapshot's principal.memberships
Custom role meta.xThe role tree's defineRoles(…, { x })Dropped; the role stayspermdock.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 }),
});
AdapterHook
permdock/server and every HTTP adapter on itcontext(request), or the framework context (c, req)
permdock/supabase middlewarecontext(ctx, request)
permdock/mcpcontext(authInfo)
permdock/ai-sdkcontext(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).
  • sqlFunction names a database function with a portable twin, for a rule that must hold in RLS too (conditions).
  • A DecisionProvider delegates 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 Response to replace the answer, or undefined for 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. Keeping x out 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, context or a relation.
  • One schema per kind. The subjectFrom* helpers already take a Standard Schema, so the same shape types app data with no declare module augmentation, and the schema runs at the boundary where the data enters.
  • App obligations share one kind. A new kind per need would break every exhaustive switch over Obligation. One namespaced variant keeps the list closed and leaves the names to you.
  • Hooks cannot grant. onDenied runs only after a refusal and is checked for a refusal status; context cannot 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

On this page