Extension interfaces
The fixed set of interfaces through which providers, verifiers, signers, stores, sinks and compilers plug into PermDock (SubjectResolver, TokenVerifier, TokenSigner, MembershipSource, RoleSource, ApprovalPolicySource, DirectoryStore, ApprovalStore, DecisionSink, SnapshotSource, LimitStore, ReplayStore, RevocationFeed, WhereCompiler, on() events), their trust classes, the in-process default each ships with, how provider principal types are extended without global augmentation, and the conformance runners in permdock/testing.
PermDock has no plugin system. What it has instead is a short list of interfaces, each with one job, one trust class and one in-process default, passed explicitly to createPermDock. A provider (Supabase, Clerk, Better Auth), a store (Postgres, Redis, PermDock Cloud), a sink (OpenTelemetry, a SIEM) or a compile target (Drizzle, MongoDB, a sync engine) implements one of them; nothing else can reach the evaluator. This page is the list. If an integration needs something not on it, that is an RFC-lite issue and a change to this page, not a new option. Application data on definitions and memberships, app obligations, UI defaults and adapter hooks are options, not interfaces: extend PermDock maps each need to one.
The interfaces
| Interface | Job | Trust class | Default in permdock | Implemented by |
|---|---|---|---|---|
SubjectResolver<TInput, TPrincipal> | Turn verified auth material into a Subject | Subject input: shapes the outcome | The policy's own subject function | subjectFromJwt, subjectFromSupabase, subjectFromClerk, subjectFromBetterAuth, subjectFromBetterSupabase, subjectFromMcp, subjectFromApiKey, subjectFromCapability, yours |
TokenVerifier<TClaims> | Establish that a JWS (or nested JWE) is genuine and return its claims, never throwing | Subject input: feeds SubjectResolver; a wrong verifier admits a forged principal | None in core (type only); joseTokenVerifier in permdock/jwt | Another JOSE library, a KMS-backed verifier, an introspection client (JOSE) |
TokenSigner | Produce compact JWS for PermDock's own outputs (snapshots, approval tokens, decision exports) | Operational: signs what core already computed; never influences an outcome | None in core (type only); joseTokenSigner in permdock/jwt | A KMS or HSM signer, permdock/cloud (wire formats) |
MembershipSource | Add named-scope and resource memberships to a principal | Subject input | What subject and context returned; memoryMembershipSource(entries) | Provider mappers, your document_members table |
RelationSource | Answer the object graph: ancestors (an object's parent chain, with restricted flags) and related (who holds a relation on one object) | Decision input, read per request through the instance's cache; it answers facts and never decides, and a failure denies | memoryRelations(permissions, { rows, edges }) | Your folder and edge tables, an OpenFGA or SpiceDB read client (relationships) |
RoleSource | Resolve tenant-defined custom roles to declared roles; list assignable roles | Subject input | memoryRoleSource(customRoles) | Better Auth organizationRole, Supabase role_permissions, WorkOS, Clerk, Auth0 organisation roles, your table |
ApprovalPolicySource | Approval requirements kept as data: approvalPoliciesFor({ tenants }) returns entries that add approval stages to matching allows | Subject input: it only tightens, and a failure denies every allowed call | memoryApprovalPolicies(entries) | Your approval_policies table (approvals) |
CredentialVerifier | Look up the Credential an opaque API key stands for: verify(key) returns it or null, never throwing for bad input | Subject input: feeds subjectFromApiKey; a wrong verifier admits a forged key | None in core (type only); apiKeyVerifier({ find }) and memoryCredentials() in permdock/server | Your api_keys table over Drizzle, Prisma or Kysely, looked up by the key's id (API keys) |
SettingsSource | Per-tenant settings: settingsFor(tenant) returns { credentials? }, the tenant's API-key rules | Subject input: it only tightens (refuses a key), never grants | memorySettings(record) | Your tenant_settings table (API keys) |
DirectoryStore | Hold the users, groups and memberships an identity provider provisions over SCIM; read back by directoryMembershipSource | Subject input, write side: what scimHandler writes becomes memberships | memoryDirectoryStore() in permdock/scim | Your scim_users / scim_groups tables over Drizzle, Prisma or Kysely (SCIM adapter); never PermDock Cloud (a Cloud-native directory reaches you as token claims, not as a store, PermDock Cloud) |
ApprovalStore | Hold pending approval-required decisions until a human answers | Operational: never influences an outcome | memoryApprovalStore() | Postgres, Redis, Durable Objects, PermDock Cloud (approvals) |
DecisionSink | Receive decision events after the fact | Operational | memorySink() | OpenTelemetry, SIEMs, a table, PermDock Cloud (audit) |
SnapshotSource | Distribute and invalidate snapshots | Operational | In-process snapshot(); memorySnapshotSource(snapshot) | PermDock Cloud, your cache (snapshots) |
PolicySource | Deliver a verified PolicyDocument of hosted grants; current() is read once by createPermDock | Policy input: may widen an outcome, bounded by hostable in code | memoryPolicySource(document) | cloud().policies (PermDock Cloud); testPolicySource in permdock/testing |
ReplayStore | Remember SET / logout_token jti values, namespaced by issuer, so a replay is dropped. remember(key, expiresAt?) is additive; the optional pair claim(key, expiresAt?) / release(key) makes concurrent deliveries dispatch once | Operational: never a decision input | memoryReplayStore() in permdock/ssf (evicts on read) | Redis SET NX EX or a Postgres expires_at row; recipes on SSF; testReplayStore in permdock/testing |
RevocationFeed | Tell open streams and sockets that a subject changed: subscribe(listener) returns an unsubscribe function, revoke({ principal, session?, tenant?, kind }) publishes. session-revoked ends matching connections; changed makes them resolve their subject again | Operational: it can end or revalidate a connection, never grant; a lost event falls back to expiry | memoryRevocationFeed() in permdock (validates and freezes events, isolates listener errors) | Postgres LISTEN/NOTIFY, Redis pub/sub or Supabase Realtime bridging revoke across replicas; testRevocationFeed in permdock/testing |
DecisionProvider | Answer decide for delegated permissions via a remote PDP, and optionally list permitted ids (permitted) for filter and where | Decision path, opt-in, fail-closed | None; local evaluation only | remotePdp, openfga and spicedb in permdock/pdp |
WhereCompiler<TTarget> | Compile the portable condition AST to a query language | Pure function of the AST | The in-memory evaluator and filter | permdock/drizzle, prisma, kysely, the RLS generator; candidates for MongoDB, Zero, ElectricSQL (local-first sync) |
on(event, handler) | Observe decision, denied, approval, auth events | Observational: cannot change a Decision | No-op | Logging, metrics, tests |
Two rules follow from the table. Subject inputs run once per createPermDock, before any check, and their output is frozen into the subject; they never run during can or decide, so a slow membership lookup costs one await per request, not one per button. Operational interfaces never sit in the decision path: decide returns before a sink is awaited, a store is consulted only on resume (and the resume re-runs decide), a snapshot source distributes what the in-process instance already computed, and a signer signs it afterwards (invariant 15). permdock/cloud implements the three operational stores and a TokenSigner for the snapshots it distributes, and none of the subject inputs; the Cloud's SCIM relay writes to an application's DirectoryStore through scimHandler and never is one. Core declares TokenVerifier and TokenSigner as types only, so the one runtime dependency rule holds: the JOSE implementation lives in permdock/jwt behind the optional jose peer.
Subject inputs
SubjectResolver
import type { StandardSchemaV1 } from "@standard-schema/spec";
interface SubjectResolver<TInput, TPrincipal extends Principal = Principal> {
(
input: TInput,
options?: { tenant?: string },
): Subject<TPrincipal> | Promise<Subject<TPrincipal>>;
}subjectFromApiKey(options) from permdock/server returns one, tenant argument included. The provider mappers take the provider's own options as their second argument, so they satisfy it once those options are bound:
const resolve: SubjectResolver<unknown, SupabasePrincipal> = (claims) =>
subjectFromSupabase(claims, { tenant: "org_id" });The same holds for subjectFromJwt, subjectFromClerk, subjectFromMcp and subjectFromBetterAuth(auth, session, options). A bound mapper ignores the tenant argument; its provider option picks the active tenant instead. createJwtSubjectResolver(options) returns (token, request?), where request feeds the DPoP and mTLS binding checks, so it satisfies SubjectResolver only as (token) => resolve(token). tests/core/subject-resolvers.test-d.ts checks each of these shapes.
A resolver never throws: an unverifiable token, a missing session or a failed schema check yields the anonymous subject and an on('auth') event with the reason (authentication). The optional tenant is the active tenant the server resolved from the request; the resolver compares it against the memberships it finds and sets principal.tenant only on a match (tenancy). SubjectResolver never receives the request. A check that needs it, such as DPoP or mTLS binding, runs in the adapter kernel or through the request argument of createJwtSubjectResolver.
TokenVerifier
interface TokenVerifier<TClaims extends JwtClaims = JwtClaims> {
verify(
token: string,
expectations: {
typ?: string | string[]; // 'at+jwt', 'JWT', 'logout+jwt', 'secevent+jwt', 'permdock-snapshot+jwt', ...
audience?: string | string[];
issuer?: string; // when not fixed at construction (Discovery supplies it)
clockTolerance?: number;
},
): Promise<VerifiedToken<TClaims> | VerificationFailure>;
}
type VerifiedToken<TClaims> = {
ok: true;
claims: TClaims;
header: { alg: string; kid?: string; typ?: string };
};
type VerificationFailure = {
ok: false;
reason: "invalid-token";
cause: TokenFailureCause;
};verify never throws and never returns a partially verified result: signature, alg, kid, typ, iss, aud, exp, nbf, iat are all checked before ok: true. TokenFailureCause is the closed list on the JWT adapter behaviour table (invalid-signature, expired, wrong-audience, wrong-token-type, alg-not-allowed, encrypted-token, ...), so an on('auth') event has the same shape whichever verifier produced it. subjectFromJwt takes a verifier; permdock/ssf takes one for SETs and logout_tokens; SnapshotSource consumers use one for signed snapshots. The built-in implementation is joseTokenVerifier({ jwks | discovery, algorithms, typ, decryptionKeys }). A verifier that calls an RFC 7662 or RFC 9767 introspection endpoint satisfies the same interface: claims is the introspection response, cause is expired for active: false. The interface has only verify: the key set is the verifier's own configuration, not something permdock doctor reads back.
TokenSigner
interface TokenSigner {
sign(
payload: Record<string, unknown>,
options: {
typ:
| "permdock-snapshot+jwt"
| "permdock-approval+jwt"
| "permdock-decisions+jwt"
| "permdock-policy+jwt"
| "permdock-capability+jwt";
audience?: string | string[];
expiresAt?: number; // NumericDate (Unix seconds); written as `exp`
},
): Promise<string>; // compact JWS
readonly kid?: string;
jwks?(): Promise<JSONWebKeySet>; // public keys, for a /.well-known/jwks.json route
}sign writes alg, kid and typ into the protected header and nothing else; it sets iat and jti itself and copies iss from its configuration, so the caller only supplies the format-specific claim (snapshot, approval, events, policy, capability) and optional aud / exp. The built-in implementation is joseTokenSigner({ key, alg, kid, issuer }); a KMS-backed signer implements the same two methods. Signing is optional everywhere: permdock.snapshot() without a signer returns the plain snapshot JSON (wire formats).
Typed provider principals
Each provider entry exports a base principal type and accepts a schema option, any Standard Schema, for the claims or fields it cannot know in advance:
import { subjectFromSupabase, type SupabasePrincipal } from "permdock/supabase";
import { z } from "zod";
const claims = z.object({
tenant_id: z.string().uuid(),
user_role: z.enum(["viewer", "admin"]),
plan: z.enum(["free", "pro"]),
});
const { data, error } = await supabase.auth.getClaims();
export const subject = subjectFromSupabase(error ? null : data.claims, {
schema: claims, // invalid claims drop `principal.claims`, never the subject
roles: "user_role",
tenant: "tenant_id",
});
// subject.principal is SupabasePrincipal; custom fields live on principal.claims| Entry | Base type | What it fixes | What schema adds |
|---|---|---|---|
permdock/supabase | SupabasePrincipal | id (sub), kind: 'user', assurance (aal), email when present | Custom access token hook claims; user_metadata is never read |
permdock/clerk | ClerkPrincipal | id, tenant (active organization), memberships[0] from org_role, fea roles | Custom session claims |
permdock/better-auth | BetterAuthPrincipal | id, tenant (activeOrganizationId), memberships from member and teamMember rows | additionalFields on the user |
permdock/jwt | JwtPrincipal | id (sub), issuer (iss), kind, assurance (acr, amr, authTime), binding (cnf), RFC 9068 roles / groups / entitlements | Any other claim |
permdock/convex | ConvexPrincipal | id (tokenIdentifier or subject), kind | Custom claims on the identity |
permdock/mcp | McpPrincipal | id from authInfo, kind, the actor and delegation halves | authInfo.extra claims |
Validation failure is not a throw: the principal becomes anonymous and on('auth') reports schema as the reason, the same fail-closed rule as an invalid token. The schema output type is intersected with the base type, so subject.plan in a condition is typed and a typo is a compile error. There is no declare module 'permdock' augmentation and no $Infer accessor: augmentation types values that may not exist at runtime and cannot be validated, and an inference accessor cannot see configuration TypeScript cannot link statically (tenancy alternatives).
Two helper types cover the policy side: PrincipalOf<typeof policy> and SubjectOf<typeof policy> are the principal and subject types the policy's subject function produces, for use in server actions, tests and handlers without re-deriving them.
MembershipSource and RoleSource
interface MembershipSource {
membershipsFor(
principal: { id: string; kind?: string },
options: { tenant?: string },
): Membership[] | Promise<Membership[]>;
list?(query: {
scope: string;
id: string;
}): MemberEntry[] | Promise<MemberEntry[]>; // { principal: { id }, membership }
version?(principal: {
id: string;
}): number | undefined | Promise<number | undefined>;
readonly claimsFirst?: boolean;
}
interface EntitlementSource {
entitlementsFor(
principal: { id: string },
options: { tenant?: string },
): string[] | Promise<string[]>;
}
interface RoleSource {
rolesFor(
tenant: string,
context?: { held: string[] },
): CustomRole[] | Promise<CustomRole[]>;
assignable?(tenant: string): string[] | Promise<string[]>;
globalRoles?(): CustomRole[] | Promise<CustomRole[]>; // scope: 'global'
}
interface ApprovalPolicySource {
approvalPoliciesFor(query: {
tenants: string[];
}): ApprovalPolicy[] | Promise<ApprovalPolicy[]>;
}Both are documented with their evaluation rules on the tenancy page. A membership may carry x and a custom role meta.x, the application's own data; an invalid one is dropped and the membership or role stays (tenancy). They are read-only from PermDock's point of view: PermDock never creates, updates or deletes a membership or a custom role, so a source is a query, not a repository. A source that throws yields no memberships or no custom roles for that request (fewer grants, never more) and an on('auth') event; it never fails the request. A throwing assignable makes nothing assignable in that tenant. globalRoles returns platform custom roles, read once per signed-in subject.
createPermDock calls rolesFor once for each tenant the subject has a live membership in, before the first check, and passes held: the role names those memberships hold there, nested scopes included, declared and custom alike. Checks are synchronous, so the source cannot be asked later, per decision.
rolesFor returns every custom role of the tenant, not only the ones the subject holds. The instance knows only the custom roles the source returned, so assignableRoles, assignablePermissions and decideRoleChange cannot offer or assign a role it left out. customRoleSource(reader, options?) builds that source from a function that reads a tenant's roles from the application's store:
import { customRoleSource } from "permdock";
const customRoles = customRoleSource({
rolesOf: (tenant) => db.customRoles.findMany({ where: { tenant } }), // every role of the tenant
globalRoles: () => db.customRoles.findMany({ where: { scope: "global" } }),
});- It keeps only the roles whose
tenantis the requested one, so a store query that returns another tenant's rows grants nothing there. assignableandglobalRoleson the reader are passed through.{ read: "held", policy }returns[]without reading when everyheldname is a declared role ofpolicy, for a tenant's roles and forglobalRoles, whichcreatePermDockcalls with the global role names the subject holds (globalRoles({ held })). Use it on requests that never manage roles, where only the roles the subject holds matter; keep the defaultread: "all"on a role-management page, a members table and the server action behinddecideRoleChange.
customRoles also takes a RoleSourceFactory, (subject) => RoleSource | undefined, called once per instance with the resolved subject (principal, actor and delegation). An adapter option is shared by every request, and a RoleSource is not told whose roles it reads, so a source that reads per user (the custom roles a principal holds, postgrestSources(client).customRoles) is passed as customRoles: (subject) => subject.principal && sources.customRoles(subject.principal) instead of keeping the last principal in a closure. A factory that throws reads no custom roles and reports source-threw; permdock.derive({ customRoles }) takes one too.
testRoleSource(source, { tenant, declared, every }) fails a source that returns fewer roles than every names with nothing held.
ApprovalPolicySource is read once per instance for the subject's tenants, alongside the custom roles. Unlike the other sources it fails toward denial: a throw, a rejected promise or an entry that does not load against the policy makes every call an allow would grant deny with reason approval and detail approval-policy-unavailable, because dropping the entries would skip an approval someone configured. An entry for a permission the policy does not declare applies to nothing. Approvals covers the entry shape and how entries combine with the code requirement.
memberships also takes an array of sources: composeMemberships merges them, keeps entries that differ in via, expiry, managedBy or seats apart, lists across every source that can list, and reports the highest version. One throwing source fails the whole lookup closed. list returns every member of one scope instance for member lists and access reviews. version is the principal's authorization version, called with the principal's id and the roles and memberships the token claims, so a source can read more in the same call when the claims show it will be needed: with the policy's fresh list, token memberships behind it are stale (Supabase token hook). claimsFirst(sources, { version?, onStale? }) sets claimsFirst: the verified token's memberships are kept unless the principal says they were truncated. With onStale: 'reread' (and a version) a token whose authzVersion is behind the source's, or absent, has its memberships read from the sources instead, so a membership added after the token was minted counts at once; the default 'deny' keeps the token's memberships and denies fresh permissions. A version that throws keeps the token's memberships and marks the subject stale. fromTable and fromJunction in permdock/supabase are SQL sources that implement all three; postgrestSources implements membershipsFor over the generated subject_for function and version over authz_version_for (or over subject_for when, with policy, the token claims a custom role) for a backend that only has supabase-js.
memoryMembershipSource(entries) is the in-process default, keyed by principal id. membershipsFor returns a copy of that principal's memberships and ignores tenant, and list matches on scope and id. memoryRoleSource(customRoles) routes a role with a tenant to rolesFor and a scope: 'global' role to globalRoles.
EntitlementSource is a subject input too: entitlements on createPermDock merges its plan names for the validated active tenant into principal.plans, so plan() grants apply. memoryEntitlementSource(byTenant) is the in-process default, and fromStripeEntitlements({ stripe, customer }) reads Stripe Entitlements through a structural client. A throwing source adds no plans and an on('auth') event with source: 'entitlements'. What a custom role may grant is bounded by the ceiling of assignable declared roles whatever the source returns (custom roles).
RelationSource
interface RelationSource {
ancestors(query: {
resource: string;
id: string;
through: string;
depth: number;
}): RelationChain | Promise<RelationChain>;
related(query: {
resource: string;
id: string;
relation: string;
}): RelationHolder[] | Promise<RelationHolder[]>;
row?(query: {
resource: string;
id: string;
}): Row | null | undefined | Promise<Row | null | undefined>;
}
type RelationChain = {
restricted?: boolean;
ancestors: { id: string; restricted?: boolean }[];
truncated?: boolean;
};
type RelationHolder = { startsAt?: number; expiresAt?: number } & (
| { principal: { id: string } }
| { group: { resource: string; id: string; relation: string } }
);through is 'parent' for the parent chain, or a link name, for which the source returns the one instance the link points to (depth is then 1). related is asked only for concrete relations (field, edge or principal), never for one declared with includes alone; core expands implication and follows group holders by asking related again for the group. The optional row returns one row by id (null when there is none); only inherit() grants read it, and without it they deny with relation-unavailable.
createPermDock(policy, user, { relations }) takes one source for the through and edge-table relation grants (relationships). Unlike the subject inputs it is read during can and decide, because what it answers depends on the row, so it follows the LimitStore rules for synchronous evaluation: a synchronous answer is used, a Promise is never awaited on the decision path, and permdock.loadRelations(permission, rows) is the async step that loads what those rows need into the instance's cache. Answers are cached per instance and query, never at module level. A missing source, a throw, a rejection, an answer that is not a chain or a holder list, or a Promise that was not loaded denies with relation-unavailable; a cycle, or a chain past depth with no holder within it, denies with relation-depth. ancestors returns at most depth entries nearest first and sets truncated when the chain goes on to a row that exists. It flags every restricted entry, and the start in restricted, and ends the walk after a restricted entry only when the resource's restricted closes 'parent' (what a restricted row stops); core stops the walk where the resource says and checks for cycles itself, so a source may return more. related may return expired holders: core compares startsAt and expiresAt with the decision's clock. whoCan reads related for every instance on the chain.
CredentialVerifier and SettingsSource
interface CredentialVerifier {
verify(key: string): Credential | null | Promise<Credential | null>;
touch?(id: string, at: number): void | Promise<void>; // after a key resolved; not awaited, errors ignored
}
interface SettingsSource {
settingsFor(
tenant: string,
): TenantSettings | undefined | Promise<TenantSettings | undefined>;
}A verifier is where an API key is authenticated, so it belongs next to the subjectFrom* resolvers, never in core: apiKeyVerifier({ find }) splits pdk_<id>_<secret><checksum>, reads one row by id, and compares the key's SHA-256 hash in constant time. subjectFromApiKey still re-validates what a verifier returns with parseCredential and checks expiry, revocation and the tenant settings itself, so a verifier that returns too much cannot widen a key. A verifier or settings source that throws denies with source-threw. Settings are read on every key use as well as at creation, so a tightened tenant rule reaches keys already issued (API keys).
DirectoryStore
interface DirectoryStore {
getUser(tenant: string, id: string): Promise<DirectoryUser | null>;
findUsers(
tenant: string,
filter: ScimFilter,
page: ScimPage,
): Promise<ScimPageResult<DirectoryUser>>;
putUser(tenant: string, user: DirectoryUser): Promise<DirectoryUser>;
patchUser(
tenant: string,
id: string,
ops: ScimPatchOp[],
): Promise<DirectoryUser>;
deleteUser(tenant: string, id: string): Promise<void>;
getGroup(tenant: string, id: string): Promise<DirectoryGroup | null>;
findGroups(
tenant: string,
filter: ScimFilter,
page: ScimPage,
): Promise<ScimPageResult<DirectoryGroup>>;
putGroup(tenant: string, group: DirectoryGroup): Promise<DirectoryGroup>;
patchGroup(
tenant: string,
id: string,
ops: ScimPatchOp[],
): Promise<DirectoryGroup>;
deleteGroup(tenant: string, id: string): Promise<void>;
groupsFor(tenant: string, userId: string): Promise<DirectoryGroup[]>;
}DirectoryStore is the one exception to "a source is a query": it is a repository, because SCIM provisioning is a write. The trust rule is the same as for the other subject inputs with one refinement. Only scimHandler writes to it, and only after authenticating the identity provider or the PermDock Cloud relay with a per-tenant credential; directoryMembershipSource(store) is the MembershipSource that reads it (groupsFor), and decide sees only the Membership[] that source returns. Every method takes the tenant first, so a store cannot answer across tenants by accident. A store that throws on read yields no memberships for that request, like any source; a store that throws on write makes the handler answer the IdP with a SCIM error and no partial state. The store lives in the application (memoryDirectoryStore() by default, your tables in production); PermDock Cloud relays provisioning into it and never holds the authoritative copy (invariant 15, PermDock Cloud). An environment in Cloud-native directory mode has no DirectoryStore at all: the Cloud is the directory of record and its facts arrive as claims on a token that subjectFromJwt verifies, so the Cloud still implements neither MembershipSource nor RoleSource.
Operational interfaces
ApprovalStore, DecisionSink and SnapshotSource are defined on their own pages (approvals, audit and observability, snapshots) and summarised here for the trust rule only. LimitStore is the one operational interface whose answer produces a denied: exhausted remaining is reason limit, and a missing store, a throw, or a thenable from consume or remaining is limit-unavailable. can never consumes. See extension interfaces.
interface LimitStore {
consume(input: {
key: string;
subjectId: string;
count: number;
per: string;
now?: number;
tenant?: string;
}): { remaining: number } | Promise<{ remaining: number }>;
remaining(input: {
key: string;
subjectId: string;
count: number;
per: string;
now?: number;
tenant?: string;
}): { remaining: number } | undefined;
}remaining must be synchronous. A Promise from either method is denied, never awaited. A store counts per subjectId and tenant (the active tenant, absent without one). per is a duration such as 'hour', '15 min' or '1 d', validated when the grant is defined; memoryLimitStore() forgets ended windows. Pass limits: memoryLimitStore() to createPermDock; omitting it fails closed on every quota grant.
The rules follow from synchronous evaluation. can, filter, simulate and an approval-required outcome only peek remaining, because UI paths call can on every render and consuming there would spend quota on a hover; decide and assert consume, since mutations call them. A Promise is truthy, so reading an async consume as a remaining count would fail open; the store is never awaited and a thenable is limit-unavailable. An async store (Redis, a database) sits behind a synchronous cache or implements only a synchronous remaining for UI peeks. Exhausted (limit) and store-down (limit-unavailable) are separate reasons so operators can tell them apart. If the first matching allow is over quota, the next matching allow is tried, as allows OR together. limits is never filled in automatically: a forgotten production store would otherwise count silently in process memory, and falling back to unlimited on a store failure would fail open.
PolicySource
PolicySource is the channel for grants authored in PermDock Cloud (hostable permissions).
interface PolicySource {
current(): PolicyDocument | null; // synchronous: the last verified document
refresh(): Promise<void>; // called by the application on its own schedule
}createPermDock(policy, user, { policies: source })callscurrent()once, when the instance is created, and merges the document into that frozen instance. No check callsrefresh()or the network, and there is no module-level policy the source swaps in the background. Every server and agent adapter forwards thepoliciesoption.- The source is only read when the policy lists
hostablepermissions. Acurrent()that throws is reported throughon('error')and treated as no document. - The application calls
refresh()on an interval, a cron or a webhook. A refresh that fails, or a document whose signature does not verify with the application'sTokenVerifier, keeps the previous document;refresh()never rejects. With no document, only code grants apply. - Hosted grants apply only to permissions the policy marks
hostable, never override a code deny, and never weaken anapprovala code grant requires; a grant outside those bounds is dropped and reported throughon('error')ashosted-grant-dropped. - The trust class is a policy input: unlike the operational interfaces it can widen an outcome, which is why it is bounded by
hostablein code. The defaults arememoryPolicySource(document)in the package (it runs the document throughparsePolicyDocument) andtestPolicySource(source, { policy })inpermdock/testing.cloud().policiesis the hosted implementation (PermDock Cloud).
interface WhereCompiler<TTarget, TOptions = unknown> {
(condition: Condition, target: TTarget, options?: TOptions): unknown;
}toWhere from each query adapter is a WhereCompiler. A compiler receives the normalised JSON tree (conditions), including the memberOf node, and must return a fail-closed value (an always-false expression, { OR: [] }, eb.lit(false)) when the condition is the empty allow set. It never receives closures; permdock.where has already excluded them and set partial. A community compiler for a new target (MongoDB, a sync engine's permission language) implements this interface and runs testWhereCompiler below; it does not become a package entry without a Why update on adapters. WhereCompiler is exported from permdock as a type so such a compiler can be typed against it.
A compiler for a query language need not walk the condition tree itself. compileWhere(condition, options) from permdock/compile lowers it, for one subject, to a CompiledWhere tree: never, always, compare, isNull, and, or, not, exists for a mapped membership table, and sql for a related subquery. Subject refs are already bound, memberOf is already an id list or an exists, and every not already admits the NULL field the in-memory evaluator treats as a failed comparison. permdock/drizzle, prisma and kysely render this tree, so a renderer is a switch over nine node kinds.
import { compileWhere, type CompiledWhere } from "permdock/compile";
const tree: CompiledWhere = compileWhere(permdock.where(permissions.post.read));testWhereCompiler(compiler, { target, isFailClosed?, matches? }) checks the empty allow set and sqlFunction twins. With matches(compiled, row), which answers whether the output selects one row, it also runs a table of comparisons, contains with literal % and _, lists, isNull, and, or and not over rows with NULL fields, and expects exactly the rows the in-memory evaluator keeps.
Why RLS and PowerSync do not render CompiledWhere
CompiledWhere is bound to one subject at request time. Generated RLS and PowerSync sync rules are compiled once per policy and read the subject in SQL at query time (auth.uid(), JWT claims, a GUC), call membership helper functions, and pass opaque SQL through. Rendering them from CompiledWhere would need a second, subject-free tree, so they keep their own compilers over the condition AST. rlsParity holds the RLS compiler to the evaluator's answers instead, including a not or a deny over a NULL column, which RLS renders as (…) is not true.
Events
permdock.on(event, handler) registers observers on the request-scoped instance:
| Event | Payload | When |
|---|---|---|
decision | DecisionEvent (permission, outcome, subject summary, tenant, membership, via, actor, delegation) | Every decide, assert, can |
denied | The same, filtered | Outcome denied |
approval | ApprovalRequest | Outcome approval-required, and on resolve |
decision sink membership | MembershipEvent | A role change from SCIM, Better Auth, Clerk or membershipEvent(); written to the DecisionSink, not on() |
decision sink credential | CredentialEvent | An API key created, rotated or revoked (credentialEvent(), memoryCredentials), or a sampled use (subjectFromApiKey); written to the DecisionSink, not on() |
auth | { reason, cause?, source } | A resolver, verifier, membership source or role source failed closed. reason is invalid-token (RFC 6750 vocabulary) for anything a TokenVerifier rejected, with the specific cause; schema, unknown-role, groups-overflow and source-threw for the others |
error | The thrown value | A closure threw or returned a thenable, or an adapter hook (onDenied, context, a protect loader) threw or returned an invalid value; the hook's default applies |
Handlers cannot change a Decision; they receive a frozen copy after the outcome is computed. Throwing inside a handler is caught and reported once.
Conformance runners
permdock/testing ships one runner per interface so an implementation can prove it honours the contract before it is used:
import {
testSubjectResolver,
testTokenVerifier,
testTokenSigner,
testMembershipSource,
testEntitlementSource,
testRelationSource,
testRoleSource,
testApprovalPolicySource,
testDirectoryStore,
testApprovalStore,
testDecisionSink,
testSnapshotSource,
testPolicySource,
testLimitStore,
testReplayStore,
testRevocationFeed,
testCredentialVerifier,
testSettingsSource,
testWhereCompiler,
} from "permdock/testing";
testSubjectResolver((claims) => subjectFromSupabase(claims, { schema }), {
valid: [okClaims],
invalid: [null, { role: "anon" }],
});
testTokenVerifier(myVerifier, { jwks: fixtureJwks }); // runs the JWT adapter behaviour table against the fixture tokens
testTokenSigner(kmsSigner, {
verifier: joseTokenVerifier({ jwks: await kmsSigner.jwks() }),
});
testMembershipSource(documentMembers, {
principals: [alice],
expect: {
alice: [{ on: { resource: "document", id: "d_1" }, roles: ["editor"] }],
},
});
testRelationSource(folderGraph, {
objects: [{ resource: "folder", id: "f_payroll", relation: "viewer" }],
expect: { ancestors: { "folder:f_payroll": ["f_hr"] } },
});
testRoleSource(betterAuthRoles, {
tenants: ["o_acme"],
declared: policy.assignable,
});
testApprovalPolicySource(approvalPolicyTable, { policy, tenant: "o_acme" });
testDirectoryStore(drizzleDirectory, { tenants: ["o_acme", "o_globex"] }); // replays the recorded Okta and Entra ID requests
testApprovalStore(postgresStore, { reopen: () => openPostgresStore() });
testLimitStore(memoryLimitStore());
testPolicySource(permdockCloud.policies, { policy });
testCredentialVerifier(apiKeyVerifier({ find }), {
key: () => issueTestKey(),
revoke: () => revokeTestKey(),
});
testSettingsSource(tenantSettings, { tenant: "o_acme" });
testWhereCompiler(toMongo, { fixtures: conditionFixtures });| Runner | Asserts |
|---|---|
testSubjectResolver | Never throws; invalid material yields the anonymous subject and an auth event; valid material yields a frozen principal whose fields match the base type and the schema; tenant is set only on a membership match |
testTokenVerifier | Never throws; every fixture token in the behaviour table produces the listed cause (alg: none, wrong typ, jku header ignored, JWE without keys, EdDSA on a non-Ed25519 key, expired, wrong audience, unknown kid); a valid at+jwt returns ok: true with the fixture claims and header; RSA1_5 and none are rejected regardless of configuration |
testTokenSigner | Output is compact JWS whose protected header has exactly alg, kid, typ; alg is in the allow-list; iat, jti and iss are set; the supplied verifier accepts it with the same typ; jwks() when present contains the signing key's public half |
testMembershipSource | Returns only well-formed memberships (one shape each: a named scope instance, the tenant / team input form, or on); with policy, every membership normalises under its named scopes; a throw yields an empty list; results are JSON; with list, every instance a principal holds lists that principal; with version, the version is a finite number or absent; tenant is passed to every membershipsFor, for a source keyed by tenant |
testEntitlementSource | Returns plan names as strings for a tenant (matching expect when given) and none without a tenant |
testRelationSource | Chains are nearest first with non-empty string ids and no longer than depth; depth: 1 returns the first ancestor with truncated when there are more; depth: 0 returns none; holders have a string principal id or a group with string resource, id and relation, and finite startsAt / expiresAt; expect.links pins the instance a link points to (resource:id>link), and a group holder is pinned as resource:id#relation; a source with row answers each object with an object or null, and an unknown id with null; an unknown object answers an empty chain and no holders instead of throwing; expect pins chains (resource:id) and holders (resource:id#relation) |
testCredentialVerifier | The live key (a string, or a function issuing one) verifies to a v1 credential whose id is the key's, twice alike; every other string (empty, not a key, a flipped secret, another id, another prefix, a trailing character) is null without a throw; with touch, touching the key's id and an unknown id leaves what the key verifies to unchanged; with revoke, the revoked key stops verifying |
testSettingsSource | The tenant's settings are plain JSON with a valid credentials block (maxTtl finite and non-negative, kinds only user / service); an unknown tenant has none |
testRoleSource | Every custom role belongs to the requested tenant; every includes entry is a declared role; with { policy }, validateCustomRole drops nothing (every grants key is inside the ceiling); assignable is a subset of the declared roles; with { every }, rolesFor(tenant, { held: [] }) returns exactly the named custom roles |
testApprovalPolicySource | Entries are plain JSON and belong to the queried tenant or to none; every entry loads against policy; an unknown tenant gets no tenant entries |
testDirectoryStore | Users and groups round-trip through put, get, find and patch; the normalised PATCH shapes from the recorded Okta, Entra ID and Google Workspace requests apply correctly; userName and externalId are unique per tenant and a duplicate is reported, not overwritten; groupsFor reflects members after every operation; a second tenant never sees the first tenant's resources; directoryMembershipSource over the store yields no memberships for an active: false user; scimHandler over the store publishes session-revoked into a RevocationFeed and onChange when a PATCH deactivates a user |
testApprovalStore | Create, get, resolve, list, expire round-trip; a resolved request cannot be resolved twice; a repeated create keeps the record; concurrent consume succeeds once; an approver from another tenant is refused; with { reopen }, an approval resumes after a restart; the token is opaque to the store |
testDecisionSink | Accepts batches including a membership event; flush is idempotent; a throw never propagates to the caller |
testSnapshotSource | Returns a snapshot, a v1 object or a compact JWS; with subscribe, subscribing and unsubscribing never throw. It does not trigger an invalidation |
testPolicySource | current() is synchronous and returns null or a document parsePolicyDocument accepts; refresh() never rejects; with { policy }, every merged grant targets a hostable permission and every other grant is reported as dropped |
testReplayStore | A remembered key is seen; an expired key is not; with claim and release, two concurrent claims succeed once and a released key can be claimed again |
testRevocationFeed | Every subscriber receives each event until it unsubscribes; a throwing listener does not stop the others; an event without a principal or with an unknown kind is rejected with a TypeError and delivered to no one. Delivery may be asynchronous |
testLimitStore | consume counts down; remaining after the cap is less than zero; remaining when present is not a thenable |
testWhereCompiler | The empty allow set compiles to a fail-closed value (false / null / undefined, or options.isFailClosed); adapters pass constant-false SQL, { OR: [] } or eb.lit(false) |
The runners are what the provider adapters run in this repository's CI and what a community implementation runs in its own. An HTTP or agent adapter has its own runners, testHttpAdapter and testAgentAdapter: build an adapter.
Last updated on
Audit and observability
Every check emits a decision event with outcome, reasons, actor and delegation; permdock/otel adds a span per check; HTTP denials are RFC 9457 Problem Details.
Building UI with PermDock
Hidden versus disabled, menus, filtered lists, tenant switchers, role chips, request-access buttons, impersonation banners, view-as previews and role editors, built from the snapshot-backed client instance with usePermission, usePermissions, useFilter, useTenant, useMemberships, useRoles, useAssignableRoles, useAssignablePermissions, useApproval, useSubject, useDescribe and describe(decision), with provider-wide slot defaults; the same names in React, React Native, Vue, Svelte and Solid.