PermDock
Adapters

Cloud

permdock/cloud is the thin, optional client for PermDock Cloud; it implements ApprovalStore, DecisionSink, SnapshotSource, ApprovalWaiter, DeliveryTransport and TokenSigner over a documented HTTP API, never sits in the decision path, and is provisioned from the Vercel Marketplace.

permdock/cloud is one implementation of the interfaces the rest of the package defines. cloud({ url, key }) returns a DecisionSink with a queryable decision log sold as compliance evidence and agent governance, an ApprovalStore with an inbox UI behind it, and a SnapshotSource that distributes signed snapshots and invalidates them on CAEP events. Nothing in this entry is consulted by can, decide, filter, where or simulate: every decision still runs in-process on the policy in your bundle. Remove the entry and the application keeps working on the in-memory defaults.

PermDock Cloud itself, the hosted service, lives in the separate PermDock-Cloud repository. This page defines what the client speaks; that repository implements it. Production hosts:

URLService
app.permdock.comDashboard and inbox
permdock.com/apiMachine API (PERMDOCK_CLOUD_URL) and the platform issuer
permdock.com/mcpRead-only MCP server

Base URL

Every Cloud route sits under one base URL, https://permdock.com/api, exported as CLOUD_BASE_URL from permdock/cloud. cloud(), cloudEndpoints(), cloudOAuth() and the CLI default to it; url or PERMDOCK_CLOUD_URL points them at a self-hosted Cloud.

Paths under the baseVersionedWhy
/.well-known/oauth-authorization-server, /oauth/*, /openapi.jsonNoThe base is the OAuth issuer, and RFC 8414 and RFC 6749 fix where its metadata and endpoints live
/v1/projects, /v1/environments/:env/*/v1PermDock's own resources; a breaking change ships as /v2 next to /v1, and cloud-api-v1.json describes v1

The OpenAPI 3.1 document of every route is served at https://permdock.com/api/openapi.json and ships in the package as permdock/schemas/cloud-api-v1.json.

Platform issuer

https://permdock.com/api is the platform issuer: the OAuth authorization server for people and their tools, separate from the per-environment issuers below that sign app tokens. permdock login signs in at it, and MCP clients of https://permdock.com/mcp register and sign in at it.

  • Metadata. RFC 8414 metadata for an issuer with a path sits at https://permdock.com/.well-known/oauth-authorization-server/api (section 3.1); the Cloud also serves it at https://permdock.com/api/.well-known/oauth-authorization-server. Its issuer is https://permdock.com/api, and a client refuses metadata with any other issuer.
  • Endpoints. /oauth/authorize (authorization code with PKCE, S256 only, including the RFC 8252 loopback redirect http://127.0.0.1:<port>/callback), /oauth/device_authorization (RFC 8628), /oauth/token, /oauth/revoke (RFC 7009) and /oauth/register (RFC 7591), all under the issuer.
  • Clients. The CLI is the public client permdock-cli. An MCP client registers itself through /oauth/register as a public client (token_endpoint_auth_method: "none") with loopback or https redirect URIs; the Cloud MCP server names the issuer in its RFC 9728 metadata at https://permdock.com/.well-known/oauth-protected-resource/mcp.
  • Connect. bettersupabase.com's Connect button is a public client the Cloud registers, with no client secret. It finds the endpoints in the RFC 8414 metadata and sends PKCE S256 and a state it checks on the callback. It asks for projects:read and policy:read, so it can list projects and read their catalog and manifest but not push. Its redirect URIs are on https://bettersupabase.com, https://www.localhost for local development and the site's Vercel preview deployments; the Cloud refuses any other. Disconnect sends the refresh token to /oauth/revoke. The project fields it shows are name, slug and updatedAt from GET /v1/projects.
  • Scopes. projects:read lists and reads the person's projects, policy:read reads a project's pushed catalog, manifest and policy, catalog:write lets permdock cloud push publish with a login instead of PERMDOCK_CLOUD_KEY, evidence:read reads a project's decision log and exports, and approvals:read reads its approval requests and verdicts. evidence:read and approvals:read are read-only: no scope lets a login write decisions or resolve an approval.
  • Revocation. /oauth/revoke answers 200 for a revoked token and for one it does not know (RFC 7009 section 2.2); permdock logout sends the refresh token.
  • Challenges. A refused token answers 401 with WWW-Authenticate: Bearer realm="permdock", error="invalid_token". A token without the route's scope answers 403 with error="insufficient_scope" and the missing scope (RFC 6750 section 3.1), so a client knows to sign in again with that scope instead of refreshing.

Projects read API

A project is one Cloud environment and shares its id, so /v1/projects/prj_123 and /v1/environments/prj_123 name the same thing. The read routes take a platform login with the scope in the table, or PERMDOCK_CLOUD_TOKEN: a read-only key the dashboard issues for one environment, which reads that project only and answers 403 on every write.

RouteScopeAnswer
GET /v1/projectsprojects:read{ items: [{ id, name, slug?, environment, issuer?, catalog?, updatedAt? }], next? }; with PERMDOCK_CLOUD_TOKEN, the one project it reads
GET /v1/projects/:idprojects:readOne project; catalog is { version, fingerprint } of the last push
GET /v1/projects/:id/policypolicy:readThe project policy { v: 1, project, version, fingerprint, permdockVersion, catalog, manifest?, policy? }, or 404 policy-not-published

Every 200 carries RateLimit and RateLimit-Policy (RateLimit header fields, draft 11); a 429 adds Retry-After. Errors are Problem Details: 401 for a missing, expired or revoked token, 403 for a missing scope or a project the token may not read, each with the challenge above, and 404 for an unknown project. A project with nothing pushed answers 404 with type https://permdock.com/problems/policy-not-published (Problem Details). GET /v1/projects pages with next: send it back as ?cursor=.

The Cloud stores what a push sent as text, so JSON member order survives and permdock cloud pull writes the bytes collect and supabase inspect --out write.

OAuth client

cloudOAuth signs an app's users in to PermDock Cloud and reads their projects. It discovers the endpoints from the issuer's RFC 8414 metadata and uses the authorization code flow with PKCE S256.

import { cloudOAuth, isCloudAuthError } from "permdock/cloud";

const permdockCloud = cloudOAuth({
  clientId: "app_123", // from the dashboard, or /oauth/register
  redirectUri: "https://app.example.com/permdock/callback",
  scopes: ["projects:read", "policy:read"], // the default
});

// 1. Start: keep state and codeVerifier server-side, then redirect.
const { url, state, codeVerifier } = await permdockCloud.authorizeUrl();

// 2. Callback: refuses a callback that does not echo state.
const credential = await permdockCloud.exchangeCode(request.url, {
  state,
  codeVerifier,
});

// 3. Read. On invalid-token, refresh; on insufficient-scope, authorize again with error.scope.
try {
  const page = await permdockCloud.listProjects(credential.access_token);
  console.log(
    page.items.map((project) => project.name),
    page.next,
  );
} catch (error) {
  if (!isCloudAuthError(error)) throw error;
  console.log(error.code, error.scope);
}
  • authorizeUrl({ scopes? }) returns { url, state, codeVerifier }, frozen. state is 16 random bytes and the verifier 32, both base64url.
  • exchangeCode(callback, { state, codeVerifier }) throws state-mismatch unless the callback echoes state, issuer-mismatch for an RFC 9207 iss of another server, and authorization-denied for an error or a missing code.
  • exchangeCode and refresh(refreshToken) return a CloudCredential with RFC 6749 names: access_token, token_type, refresh_token, scope and expires_at in seconds since the epoch. A refused grant throws token-refused with the server's error.
  • listProjects(accessToken, { cursor? }) returns { items, next? } of CloudProject, checked against the Project schema; a list that does not match throws unexpected-response.
  • clientSecret makes a confidential client that authenticates with client_secret_basic. Without it the client is public and sends client_id in the body.
  • Every failure is a CloudAuthError with a code: discovery-failed, state-mismatch, issuer-mismatch, authorization-denied, token-refused, invalid-token, insufficient-scope (with scope), forbidden, rate-limited, unreachable or unexpected-response. status and error carry what the Cloud sent. isCloudAuthError matches by name and code.
  • The client holds no tokens. Store them like any other credential, server-side.

Identity gateway

The Cloud is also an identity gateway: each environment is an OAuth issuer whose issuer URL is the environment URL <PERMDOCK_CLOUD_URL>/v1/environments/<env> (cloudEndpoints().issuer), with /.well-known/openid-configuration next to its /.well-known/jwks.json. It issues tokens through five grants: authorization code with PKCE for people (/oauth/authorize, S256 only), the RFC 8628 device flow for agents and CLIs (/oauth/device_authorization), client credentials for workloads, RFC 8693 token exchange for confidential clients that already hold an upstream ID token, and refresh_token. It maps Agent Auth capability grants onto permission references. The only OSS integration is subjectFromJwt({ discovery: cloudEndpoints().issuer, audience }). There is no cloud().identity helper, and the Cloud still never sits on the decision path.

The gateway exists because the Cloud already signs with a per-environment JWKS, and an OIDC Discovery document next to it is the other half of an issuer, not a new product. Humans and agents that use the inbox, the evidence export, the read-only MCP server and the hosted ADS need a first-party way to get those tokens. It is deliberately not a login SDK:

  • Federation, not a session layer. An upstream IdP (Clerk, Auth0, WorkOS, Google, enterprise SSO, any Discovery issuer) authenticates; the Cloud issues the access token the application and the ADS consume. The application keeps its own sessions.
  • One capability vocabulary. Agent Auth capability grants on a device-flow token name permission key or scope values, never a second set of names.
  • The library still does not authenticate. Core never verifies a token; permdock/jwt verifies the Cloud's token exactly as it verifies any other issuer's, and there is no permdock/cloud-auth entry. A Cloud outage leaves the embedded engine deciding from the application's own subjectFrom* and tokens already issued.

Directory modes

Each environment picks one directory mode:

ModeDirectory of recordHow facts reach decideCloud outage
Cloud-nativeThe Cloud: users, groups, memberships, held declared roles and plansClaims on the Cloud-issued access token (roles, groups, entitlements, the tenant claim per JWT authorization claims), verified by subjectFromJwt in your appNew logins fail; issued tokens keep deciding until they expire
Bring your ownYour tables, Clerk, Okta, Entra ID or Better AuthThe provider's token through its subjectFrom*, and your DirectoryStore / MembershipSource, fed by the SCIM relay when you use itNothing changes; memberships stay at their last synced state

In both modes permdock/cloud implements neither MembershipSource nor CustomRoleSource. Switching modes changes a connector and a claim mapping, not your policy.

In Cloud-native mode there is no new entry: the application verifies the Cloud's access token with permdock/jwt, like any other issuer.

import { cloudEndpoints } from "permdock/cloud";
import { subjectFromJwt } from "permdock/jwt";

const subject = await subjectFromJwt(request, {
  discovery: cloudEndpoints().issuer, // the environment URL; issuer, JWKS and algorithms from OIDC Discovery
  audience: "https://app.example.com",
  claims: { tenant: "org_id", memberships: "memberships" }, // the environment's tenant claim, `org_id` by default
});
Claim on a Cloud-native access tokenCarriesRead as
subThe Cloud user idprincipal.id
rolesDeclared global roles (no on) the principal holds in the active tenant, from assignments plus group memberships at issue timeprincipal.roles
memberships[{ scope, id, within?, roles }] in the named scopes form, for declared assignable roles with on a declared scope; subjectFromJwt also reads the older [{ tenant, roles, team? }] formprincipal.memberships
The tenant claim (org_id unless the environment configures another name)The active tenant of the exchangeThe active tenant (claims.tenant), kept only when a membership matches it
groupsDirectory group idsTeam memberships through groupRoles or a MembershipSource
entitlementsPlans heldprincipal.plans

The token endpoint is POST <env URL>/oauth/token (RFC 6749 form body). /.well-known/openid-configuration lists every grant in grant_types_supported:

grant_typeClientInput
authorization_codePublic or confidentialThe code from /oauth/authorize and the PKCE code_verifier (RFC 7636); code_challenge_method must be S256, plain is refused
urn:ietf:params:oauth:grant-type:device_codePublic (an agent, a CLI)The device_code from POST /oauth/device_authorization (RFC 8628) once the person approved the user_code
client_credentialsConfidential only (client_secret_basic)Nothing else; the token's sub is the client and aud the environment URL, for workload callers such as a PEP calling the hosted ADS
urn:ietf:params:oauth:grant-type:token-exchangeConfidential only (client_secret_basic)An upstream ID token, below
refresh_tokenThe client the refresh token was issued toA refresh token; rotated on each use

Token exchange takes:

ParameterValue
grant_typeurn:ietf:params:oauth:grant-type:token-exchange
subject_tokenAn ID token from a trusted-issuer connector; the Cloud verifies it with permdock/jwt and maps its sub to a directory principal
subject_token_typeurn:ietf:params:oauth:token-type:id_token
audienceYour application; becomes the access token's aud
tenantOptional: the active tenant; defaults to the principal's only tenant

The response is RFC 8693 { access_token, issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', token_type: 'Bearer', expires_in }. The access token is an RFC 9068 JWT (typ: at+jwt) signed with the environment key: iss is the environment URL, aud the requested audience, sub the directory principal id, plus the claims above. Lifetimes are short (five minutes by default); there is no proprietary token-minting route and no endpoint that returns a subject for a request-time lookup.

The Cloud mints only names the pushed catalog declares (permdock cloud push), and the application still drops a role its policy does not declare and ignores a tenant with no matching membership. The package's scenario tests cover the contract with signed fixture tokens: a Cloud-native token decides locally, an undeclared role grants nothing, a tenant with no membership is no tenant, and a token for another audience is anonymous.

Two modes exist because two kinds of team ask for opposite things. Teams without an identity provider want one place to manage people, groups and held roles without building an admin UI; for them an issuer that knows nothing about roles is useless. Teams on Clerk, Okta, Entra ID, Better Auth or their own tables will not move their directory of record and want the Cloud only for federation, the provisioning relay and evidence. Both modes keep the same line: a directory that reaches the engine only as signed claims on a token verified locally sits on the login path, not the decision path, which is the pattern Clerk already uses with org_role, pla and fea. Letting permdock/cloud implement MembershipSource behind a cache was rejected because a remote table on the decision path is the failure mode of hosted control planes, and a cache does not change who is authoritative during an outage. Providers that want to act as a Cloud directory or issuer speak OIDC Discovery with JWKS, SCIM 2.0 and the PermDock claim table; there is no PermDock-specific provider interface and no per-vendor entry. Self-hosters get the same shape with their own issuer, subjectFromJwt, and their own DirectoryStore behind an admin screen.

  • Assignment UI. The Cloud edits users, groups, memberships and which declared roles and plans a principal holds. It reads roles, plans and permissions from the catalog permdock collect produces, never accepts a free-text role, and only hands out roles the policy declares assignable, narrowed by the tenant's assignable set and by what the acting admin holds there.
  • Freshness. A removal takes effect at the next token issue or refresh. Cloud-native access tokens default to a lifetime of minutes; for faster revocation the Cloud's CAEP transmitter sends session-revoked or credential-change for the subject (SSF adapter) and the snapshot edge invalidates signed snapshots. Your app never looks the directory up live.
  • Evidence. Every assignment or removal writes a membership event with source: 'cloud' and by set to the Cloud admin, so "who gave this person that role" is a decision-log query.
  • Hosted grants. Editing grants themselves, not only assignments, on permissions the code marks hostable (below).

Hosted grants

A Cloud admin can write grants such as "the pro plan may read audit logs" without a deploy, bounded by the code policy:

  • The Cloud publishes portable grants as a PolicyDocument (v: 1, wire formats) under the policy claim of a compact JWS with typ: vnd.permdock.policy+jwt, signed by the environment key. cloud().policies.refresh() fetches it, verifies it with the verifier you pass to cloud() (checking typ, exp, and iss and aud against the environment URL <PERMDOCK_CLOUD_URL>/v1/environments/<env>), and keeps it. The Cloud sets exp to 24 hours after issue, so an instance that stops refreshing falls back to its code policy. An unsigned, wrongly typed or unverifiable document is absent, never partially applied, and the last verified document stays; without a verifier the source never holds a document.
  • cloud().policies is a PolicySource; pass it as policies: hostedPolicies(permdockCloud.policies). createPermDock reads its current() document once per instance; the application calls refresh() on its own schedule (an interval, a cron, or the catalog webhook), so no check waits on the Cloud.
  • Hosted grants apply only to permissions the policy marks hostable, target only declared roles, plans and relations, never override a code deny and never remove an approval a code grant requires. The merge rules are on policies and the interface on extension interfaces.
  • A decision matched by a hosted grant records the document fingerprint and grant id in matched.hosted, so the decision log can answer which hosted grant allowed it.
import { getPermDock, hostedPolicies } from "permdock";
import { cloud } from "permdock/cloud";
import { joseTokenVerifier } from "permdock/jwt";

const permdockCloud = cloud({
  verifier: ({ jwks }) =>
    joseTokenVerifier({ jwks, algorithms: ["Ed25519", "ES256"] }),
});
await permdockCloud.policies.refresh();
setInterval(() => void permdockCloud.policies.refresh(), 60_000);

const permdock = await getPermDock(policy, user, {
  policies: hostedPolicies(permdockCloud.policies),
});

A snapshot source never delivers hosted grants, because a snapshot is a per-subject result the server computed, not a policy; a snapshot the application builds from an instance already reflects that instance's merged grants. Fetching the policy per request would put the Cloud on the decision path.

Authoring hosted grants

A grant editor checks candidates with the merge rules themselves, from permdock/catalog, against the policy policyFromCatalog rebuilds:

  • checkHostedGrant(policy, grant) answers { ok: true } or { ok: false, reason } for one candidate, with the HostedGrantDropReason mergeHostedGrants would report (not-hostable, unknown-permission, unknown-grantee, non-portable, weaker-approval, invalid). It needs no PolicyDocument. hostedGrantFindings is not a substitute: it reports drift between catalogs, so it skips non-portable and invalid, which a catalog change cannot cause.
  • hostedGrantOptions(policy) lists, per hostable permission, what a grant may say: grantees.roles (declared roles grouped by the scope they are held at, global for a role with no on), grantees.plans, grantees.relations (with through: 'parent' on a self-parented resource), approval (required and meets when a code allow carries an approval, modes human or any, the object-form fields, staleOn only on a resource with a version, and the approver kinds) and conditions (the portable subset, over the resource's schema fields).
import {
  checkHostedGrant,
  hostedGrantOptions,
  policyFromCatalog,
} from "permdock/catalog";

const policy = policyFromCatalog(catalog, pushed);
const options = hostedGrantOptions(policy); // fills the New grant form
const check = checkHostedGrant(policy, candidate);
if (!check.ok) {
  // show check.reason; the Cloud API answers 422 with the same reason
}

Purpose

Four things are worth paying for around an embedded decision engine, in the order PermDock Cloud leads with them: a place where decisions accumulate as evidence, so an access review, an agent-activity review or "which agent did what for whom" is a query with a signed export rather than a log grep; a way for services that do not run TypeScript, and for API gateways, to enforce the same policy; a place where pending approvals wait for a person with a UI and notifications; and a hosted SCIM endpoint that relays directory provisioning into a store your application owns. PermDock Cloud provides all four as an Authorization Decision Service plus control plane, and permdock/cloud is the client for the first three (the fourth targets permdock/scim in your app). The Supabase model applies: every capability has an interface in the open-source package with an in-process default, the Cloud is the managed implementation, and the interfaces are documented well enough to implement yourself (approvals adapter, audit).

Design rules

  • An embedded PDP, an optional ADS. PermDock is a policy decision point that runs inside your process, and it never needs a network call to decide. Hosted authorization products put every check behind a request and take strings in and booleans out; that adds latency to every check and makes the service a failure mode for the application. The pdp provider is the one opt-in exception, for permissions the application explicitly delegates to a remote PDP. The docs call the embedded engine the PDP and the hosted endpoint the Authorization Decision Service (ADS), never the other way round.
  • No forced subscription. Everything the Cloud does, an application can do on its own with the interface and its in-process default, the way Supabase users can run Postgres themselves. That is why approvals and the decision log are interfaces rather than Cloud features, and why a gateway or MCP proxy in the decision path was rejected.
  • One package, one server-only entry. The client is a subpath of permdock, not a separate @permdock/cloud package, so consumers and the permdock-wire skill have one install path and one version line; an app that never imports it pays nothing. The hosted service, its persistence, UI and billing live in the separate PermDock-Cloud repository and follow this repository's wire formats.
  • Evidence leads. Buyers with budget pay for proof of who could do what and a way to stop it: procurement for access reviews and audit evidence, security teams for agent governance. The decision event (principal, actor, delegation, tenant, outcome) and the bound approval token are that proof, so the decision log is the lead capability and the inbox is a feature on top. What makes an approval trustworthy is not the inbox but the resume rule: re-run decide and recompute the token before trusting a stored approval.
  • Every event is evidence. Sinks receive every granted, denied and approval-required event; sampling is opt-in and never applies to the evidence log, because access reviews are about what was allowed. Ingest cost is handled by retention tiers.
  • Governance actions never decide. The Cloud can reject pending approvals and invalidate snapshots; it cannot deny a future check. A "freeze this agent" action is those two operations plus a kill-switch table the application's own CustomRoleSource or context reads.
  • A relay, not a membership source. The hosted SCIM endpoint replays provisioning into a DirectoryStore your application owns, so self-hosters get the same feature from permdock/scim and the Cloud has one documented target instead of writing into arbitrary customer databases.
  • Approvals are resolved by people. No approval is resolvable through the Cloud's MCP server; approver identity comes only from the Cloud's own authentication, because an agent approving an agent's action is a denial the model can talk its way past.
  • No authentication product and no gateway in the decision path. The identity gateway above federates and issues tokens; it does not replace the application's login SDK, and it never decides.

API

import { hostedPolicies } from "permdock";
import { cloud } from "permdock/cloud";

const permdockCloud = cloud({
  url: process.env.PERMDOCK_CLOUD_URL, // set by the Marketplace integration or by hand
  key: process.env.PERMDOCK_CLOUD_KEY, // server-only; never in NEXT_PUBLIC_* or a client bundle
  environment: "production", // defaults to PERMDOCK_CLOUD_ENV, then the key's environment
});

// Plug into any adapter that accepts store / sink / policies
export const { getPermDock, getPermission, PermDockProvider, permdockHandler } =
  createPermDock(policy, {
    subject,
    store: permdockCloud.approvals, // ApprovalStore: persistent, inbox UI, delivery
    sink: permdockCloud.sink, // DecisionSink: batched, retried, queryable
    policies: hostedPolicies(permdockCloud.policies), // hosted grants on hostable permissions (optional)
  });
  • cloud() returns a frozen object; approvals, sink and policies are the interfaces, snapshotsFor(token) returns one SnapshotSource per subject, catalog publishes the catalog, endpoints() resolves the environment URL <PERMDOCK_CLOUD_URL>/v1/environments/<env> as issuer and the environment's JWK Set URL as jwks. The fields issuer and jwks are set at construction only when the environment is configured. cloudEndpoints() returns the same issuer and jwks synchronously, without a key, and throws when neither environment nor PERMDOCK_CLOUD_ENV is set; await resolveCloudEndpoints() asks the Cloud with the key instead. url defaults to PERMDOCK_CLOUD_URL, then CLOUD_BASE_URL. There is no decide on it.
  • verifyWebhook(request, { verifier }) (or { jwks, secret, replay }) and parseCloudEvent verify the Cloud's Standard Webhooks deliveries of CloudEvents batches (Cloud integrations); there is no unsigned mode.
  • policies holds the last verified policy document (hosted grants). It needs verifier: a TokenVerifier over jwks, or a function cloud() calls once with the resolved endpoints, so the environment need not be known at import; the expected iss and aud are the environment URL, so there is no audience to configure. Without a verifier current() stays null.
  • sink batches events in memory and flushes on size (flushAt) and at the end of a request when the runtime exposes waitUntil. Each flush posts a CloudEvents JSON batch with Content-Type: application/cloudevents-batch+json: an array holding one toCloudEvent(event, { source }) envelope per SinkEvent, where source is cloud({ source }), an absolute URI that defaults to the environment URL (CloudEvents, wire formats). A failed batch is re-queued, bounded by capacity (default 10 000, the same as memoryDecisionSink), and the oldest events are dropped first, so a Cloud outage never grows memory without limit. Every event is written; sampling is opt-in and never applies to the evidence log. Delivery failure never affects a decision and is reported through on('error'). The Cloud's export endpoint serves the same events back as signed batches (vnd.permdock.evidence+jwt), OCSF or CSV. The sink does not accept permdock/otel spans: traces go to your collector (the Cloud can export a copy of the log to it as OTLP) and evidence goes to the sink (audit and observability).
  • approvals speaks the same ApprovalRequest wire shape as the in-memory store (v: 1, with the optional approvers and subject.session); a Cloud inbox rejects an unknown major rather than approve without checking eligibility. The inbox UI, Slack, Teams and email delivery, and approver groups are Cloud features layered on the same records. Slack and Teams delivery is the Vercel Chat SDK recipe from the approvals adapter "Delivery" section, run by the Cloud instead of by your application; a self-hoster who copies that recipe gets identical cards and the same signature-verified responder.
  • approvals.resolve proves who resolved with a PermDock-Approver token signed by approver.signer (approver token).
  • snapshotsFor(token) lets clients fetch a snapshot from the Cloud edge instead of from the application server. token is an at+jwt naming the subject, sent as the PermDock-Subject header: one the environment issued (Cloud-native mode, aud includes the environment URL), or one the application signed with a trusted issuer registered for purpose subject (bring-your-own mode). In both, exp is at most five minutes after iat and the Cloud refuses a jti it has seen, the checks verifySubjectToken in permdock/jwt makes (JWT adapter); the Cloud never takes a subject from a query string or a body. It honours session-revoked and credential-change CAEP events received by the Cloud's SSF receiver (Shared Signals). Everything the Cloud serves is signed: a compact JWS with typ: vnd.permdock.snapshot+jwt and the unchanged Snapshot object under the snapshot claim (wire formats).
  • catalog.push(catalog) posts a CatalogDocument and its catalogFingerprint to POST /catalog and returns what the Cloud answers: { version, fingerprint, findings } for a new version (201, the drift findings against the previous one, as catalogDrift computes them), or { version, fingerprint, unchanged: true } (200). A non-2xx status or another shape throws. The document carries operations when it was built with an operations module, so the Cloud's dashboards and drift checks name the operations bound to each permission. permdock cloud push is the CI path to the same route.

Approver token

The Cloud never takes the approver from a resolve body (invariant 11). Each approvals.resolve(token, verdict) request therefore carries a PermDock-Approver header: a compact JWS the application signs for the person in verdict.by, with the TokenSigner it passes as approver.signer.

import { cloud } from "permdock/cloud";
import { joseTokenSigner } from "permdock/jwt";

const permdockCloud = cloud({
  approver: {
    signer: joseTokenSigner({
      key: privateJwk,
      alg: "Ed25519",
      kid: "app-2026",
      issuer: "https://app.example.com",
    }),
  },
});
FieldValue
typat+jwt (RFC 9068)
issThe signer's issuer
subverdict.by.principal.id
audapprover.audience, default <issuer>/approvals with issuer the environment URL (cloud().issuer)
iatThe signing time
expiat plus 60 seconds
jtiA fresh UUID per resolve; the Cloud refuses a jti it has seen
actNever present
roles, memberships, tenantverdict.by.principal's, in the claim-table form above

authorization_details (RFC 9396) binds the token to one verdict on one request. Before it signs, resolve reads the request through GET /approvals/by-token and adds one entry:

{
  "authorization_details": [
    {
      "type": "permdock_approval",
      "id": "apr_…",
      "status": "approved",
      "etag": "\"sha-256:…\"",
      "permissions": ["post.moderate"],
      "relations": []
    }
  ]
}

id is the request id and status the verdict's. etag is present when the Cloud sent an ETag on that read. The Cloud refuses a token whose entry names another request, another status or a stale ETag.

The entry also carries the approver's facts, the only input the Cloud checks a request's approvers against in bring-your-own mode:

  • permissions: the holder(permission) approvers the person holds, from approver.permissions(request, by), typically approverPermissions(request, permdockFor(by)); without the option, verdict.permissions.
  • relations: the relation approvers the person holds on the request's resource, from approver.relations(request, by), typically approverRelations(request, by, { relations, permissions }); without the option, verdict.relations.

With roles, memberships and tenant, and vouched when the verdict names an application rule, these are everything the Cloud checks eligibility against. A fact that is missing means not held, so a holder() or relation() approver it does not list answers 403 approver-not-eligible. A reader that throws signs none. In Cloud-native mode the Cloud computes the same facts itself, from its directory and the policy policyFromCatalog rebuilds from the pushed catalog, and ignores the signed ones.

  • Register the signer's JWK Set in the environment as a trusted issuer with purpose approver (POST /trusted-issuers). sub must equal the approver's subject in the Cloud directory, and a registered audience must equal aud.
  • resolve throws ApprovalError before sending a resolve when it cannot prove a person. The codes are approver-unauthenticated (no approver option, an anonymous principal, an empty id, or a signer that throws) and approver-not-eligible (a verdict whose subject has an actor, which is an agent approving). A failed read of the request throws the code the Cloud names, approval-not-found by default.
  • A system rejection carries no header, because no person resolved it. That is status: 'rejected' with by.actor.kind: 'system', which cancelApprovals and expiry send.
  • cloudSigner refuses at+jwt. A token signed with the environment key would let any holder of the client key approve as anyone.

Run waits, delivery and signing

Three more clients implement the interfaces of automations and delivery. Each takes url, key, environment and fetch like cloud(), throws at construction without a URL or key, and is optional: the in-process default is listed next to it.

ClientImplementsIn-process default
cloudApprovalWaiter({ store | waiter })ApprovalWaiter and a webhookpollingApprovalWaiter({ store })
cloudDeliveryTransport(channel)DeliveryTransport<channel>resendTransport, chatSdkTransport, webhookTransport
cloudSigner({ audience })TokenSignerjoseTokenSigner
import {
  cloud,
  cloudApprovalWaiter,
  cloudDeliveryTransport,
  cloudEndpoints,
  cloudSigner,
} from "permdock/cloud";
import { memoryReplayStore } from "permdock/ssf";

const permdockCloud = cloud();
export const waiter = cloudApprovalWaiter({
  store: permdockCloud.approvals,
  jwks: cloudEndpoints().jwks,
  replay: memoryReplayStore(),
});
export const POST = waiter.webhook; // the route the environment's webhook connector posts to

const email = cloudDeliveryTransport("email");
const tickets = cloudSigner({ audience: "https://app.example.com" });
  • cloudApprovalWaiter parks a run until the Cloud reports its approval. webhook(request) verifies the Standard Webhooks delivery with verifyWebhook and calls wake(token) for each com.permdock.approval.resolved, .expired and .cancelled event. It answers 204 once handled or replayed, and Problem Details otherwise: 400 for missing or malformed signature headers, a stale timestamp or a body that is not a PermDock CloudEvents batch, 403 for a signature that does not verify, and 503 when it cannot fetch the signing keys. It never answers 401, because a webhook has no authentication scheme to name in the WWW-Authenticate header RFC 9110 requires with it. With store, a wait also polls the store every 60 seconds, for a webhook another process received. With waiter, wait and wake go to a durable runner's waiter. approvalTokensOf(events) is the token extraction on its own, for a runner that receives the webhook elsewhere.
  • cloudDeliveryTransport posts each recipient the router already checked to POST /deliveries: the channel, the delivery id, the address, the principal and the permission that let it through, and the message. The Idempotency-Key header is the router's per-recipient key, so a retry repeats it. A target that fails isDeliveryTarget is refused as invalid-recipient without a request. Its statuses map as follows: 429 is rate-limited and 408, 409 and 5xx are unavailable (all retryable); 401 and 403 are unauthorized, 400 and 422 are invalid-message, and anything else is rejected (all final). Delivery outcomes come back as com.permdock.delivery.* events.
  • cloudSigner signs with the environment's key, typically KMS-held, through POST /sign, and the response is a compact JWS whose typ must match the request. It is meant for run tickets (vnd.permdock.run+jwt), approval, capability and support-session tokens. It refuses vnd.permdock.policy+jwt, vnd.permdock.snapshot+jwt and vnd.permdock.evidence+jwt, because those are the Cloud's own outputs and a client key that could mint them could forge them. It also refuses at+jwt, the approver token, which must come from the application's own key. It requires an audience. The Cloud sets iss to the environment URL and drops iss, aud, exp, iat, nbf and jti from the payload in favour of the signing options. jwks() returns the environment's key set, or no keys when it is unreachable.

None of the three is consulted by decide. A wake ends a wait and the run decides again with its token, so a forged or replayed webhook wakes a run that then finds the request still pending. A transport sends only what the router granted, and the Cloud never receives a decision to make. A signer outage fails the call that needed the token, not a decision.

Key kinds

Each environment issues four kinds of API key. None of them holds the signing key or stands in for a subject:

KindVariableReaches
clientPERMDOCK_CLOUD_KEYWhat the running application and CI call: decisions, approvals, snapshot, policy, deliveries, sign and POST /catalog
adminPERMDOCK_CLOUD_ADMIN_KEYAuthoring: hosted grants, directory principals, groups and assignments, connectors. Never deployed with the application
exportSet per export destinationGET /export only
readPERMDOCK_CLOUD_TOKENOne environment, read-only: GET /v1/projects and cloud pull. It never writes and holds no signing key

A leaked client key cannot widen access: it can push a catalog, but the application merges hosted grants only on permissions its own code marks hostable, and authoring a grant needs the admin key or the dashboard. It can ask for run, approval, capability and support-session tokens through POST /sign, so rotate it like a signing credential; it cannot mint a policy document, a snapshot, an evidence export or an approver token.

Keys

The Cloud signs with a per-environment key through the TokenSigner interface (extension interfaces) and publishes the public half as a JWK Set at <PERMDOCK_CLOUD_URL>/v1/environments/<env>/.well-known/jwks.json (cloud().jwks). cloud().snapshotsFor(token).get() asks for application/jwt and returns only a compact vnd.permdock.snapshot+jwt; an unsigned body is an error, never a snapshot. The client PermDockProvider that receives it verifies it with joseTokenVerifier({ jwks: cloud().jwks, typ: 'vnd.permdock.snapshot+jwt' }); a consumer in another language points its JOSE library at the same JWKS. Algorithms are Ed25519 by default, ES256 on request for consumers whose libraries lack Ed25519; never RS256 in a FAPI 2.0 environment. Rotation follows the JWKS rule: the new kid is published, the old key stays until the last snapshot signed with it has passed exp, and the dashboard shows both. Signed decision-log exports (typ: vnd.permdock.evidence+jwt) use the same key and endpoint (audit and observability). The private key never leaves the Cloud. The PERMDOCK_CLOUD_KEY API key is unrelated to it; it reaches the key only through POST /sign, for the token types cloudSigner allows.

  • Zero required dependencies: the client uses fetch and the wire formats from core. Each request is aborted after 10 seconds, combined with any signal you pass, and then fails like an unreachable Cloud. It is server-only and tests/bundle asserts no client entry reaches it.

HTTP API

The client speaks these paths under PERMDOCK_CLOUD_URL. In production that URL is https://permdock.com/api; a self-hosted deployment of the Cloud repository works by pointing PERMDOCK_CLOUD_URL at it, with no Marketplace involvement and no discovery step. :env is environment, then PERMDOCK_CLOUD_ENV, then the environment GET /v1/environment names for the key; cloud() asks once per instance, on its first request, and asks again after a failed lookup. VERCEL_ENV is not read: one key belongs to one environment. The OpenAPI 3.1 document of every route below is permdock/schemas/cloud-api-v1.json, and testCloudApi checks a Cloud against it.

MethodPathInterface
GET/v1/environmentendpoints() when no environment is configured: { environment, issuer, jwks_uri } for the key, under PERMDOCK_CLOUD_URL rather than an environment URL
POST/v1/environments/:env/decisionssink.write / sink.flush: a CloudEvents JSON batch, Content-Type: application/cloudevents-batch+json, one toCloudEvent envelope per event
POST/v1/environments/:env/approvalsapprovals.create
GET/v1/environments/:env/approvals/by-tokenapprovals.get: the token in the PermDock-Approval header, never in the URL (unreachable or unknown → null)
GET/v1/environments/:env/approvals/:idapprovals.getById (unreachable or unknown → null)
POST/v1/environments/:env/approvals/by-token/resolveapprovals.resolve, token in PermDock-Approval, approver token in PermDock-Approver unless the verdict is a system rejection (401 approver-unauthenticated, 403 approver-not-eligible or approver refused, 409 not pending, 410 expired, 404 missing). A body code that names an ApprovalError code, with its detail, becomes the thrown error
POST/v1/environments/:env/approvals/by-token/consumeapprovals.consume, token in PermDock-Approval: the approved record with consumedAt set, or 409 when it is not approved, already consumed or expired (any failure → null)
GET/v1/environments/:env/approvalsapprovals.list: ?status=, principalId, actorId, tenant, session, limit, cursor; returns { items, next? } (unreachable → { items: [] })
POST/v1/environments/:env/approvals/expireapprovals.expire
POST/v1/environments/:env/approvals/cancelapprovals.cancel (what cancelApprovals calls on session revocation): body { filter, by, note? } with an ApprovalListFilter; rejects the matching pending requests with resolvedBy: 'system:<by>' like the in-memory store and returns { cancelled }; a non-2xx status throws
GET/v1/environments/:env/snapshotsnapshotsFor(token).get with accept: application/jwt and the subject's at+jwt in PermDock-Subject: a compact vnd.permdock.snapshot+jwt as the body; 401 for a missing, expired or replayed subject token, 403 for an issuer not registered for subject; an unsigned JSON body throws
POST/v1/environments/:env/catalogpermdock cloud push: body { fingerprint, catalog, policy?, manifest?, permdockVersion } with PERMDOCK_CLOUD_KEY or a login holding catalog:write (cloud) and catalog.push: body { fingerprint, catalog }; 201 { version, fingerprint, findings } for a new version, 200 { version, fingerprint, unchanged: true } when the fingerprint is already published
POST/v1/environments/:env/deliveriescloudDeliveryTransport: body { channel, delivery, target: { address, principal?, provider?, workspace?, trusted?, permission }, message } with an Idempotency-Key header; 2xx { id } once accepted
POST/v1/environments/:env/signcloudSigner: body { typ, claims, aud, exp?, iat?, jti? } with accept: application/jwt; the compact JWS as the body. The Cloud refuses policy, snapshot and evidence types; the client refuses at+jwt
GET/v1/environments/:env/policypolicies.refresh: a vnd.permdock.policy+jwt compact JWS as the body; 404 means no hosted grants and clears the document, any other failure keeps the last verified one

Authoring and export routes, used by the dashboard, scripts and the contract tests, never by cloud(). Validation failures are Problem Details (wire formats):

MethodPathKeyPurpose
GET/v1/environments/:env/catalog, /catalog/versionsclientThe latest catalog and its versions
GET, POST/v1/environments/:env/hosted-grantsadminList ({ items }) or author a hosted grant: body a HostedGrant without id, 201 with the stored grant; a grant mergeHostedGrants would drop is 422 with the drop reason as reason
DELETE/v1/environments/:env/hosted-grants/:idadminRemove a hosted grant; 204. Every change issues a new policy document
GET, POST/v1/environments/:env/directory/principals, /directory/groupsadminCloud-native directory: list ({ items }) or create (201 { id }); a principal is created with the trusted-issuer subject it federates from
GET, POST, DELETE/v1/environments/:env/directory/assignments[/:id]adminRole assignments { principal | group, tenant, team?, role }: only catalog roles marked assignable, narrowed to roles the acting admin holds there; each change writes a membership event with source: 'cloud'
GET, POST, DELETE/v1/environments/:env/connectors[/:id]adminIntegrations: { kind, name, config } validated against schemas/connector-v1.json, one branch per kind: webhook, otlp, siem, evidence, scim, caep-transmitter, federated-upstream, chat, email, vercel-oidc (Cloud integrations)
GET, POST, DELETE/v1/environments/:env/trusted-issuers[/:id]admin{ issuer, jwks_uri | discovery, audiences, purposes } with purposes from approver, subject and token-exchange: whose tokens the Cloud accepts, and for what
GET, POST, DELETE/v1/environments/:env/trusted-peps[/:id]adminCallers the hosted ADS takes a subject from the body for: { vercel: { team_id, project_id?, environment? } } or { client_id }
GET, POST, DELETE/v1/environments/:env/approver-groups[/:id]admin{ name, members }: who an approval notification goes to. Eligibility still follows the request's approvers
POST/v1/environments/:env/snapshots/invalidateadmin{ principal?, session?, tenant?, reason }: drops the Cloud edge's snapshots for the match, sends CAEP session-revoked on the environment's SSF streams, answers 202; it never denies a check
GET/v1/environments/:env/export?format=jws|ocsf|csv|otlpexportThe decision log as a vnd.permdock.evidence+jwt, OCSF (toOcsf), CSV (toCsvRow) or OTLP logs
GET/v1/environments/:env/oauth/authorizenoneAuthorization code with PKCE; code_challenge_method=S256 required, plain refused (identity gateway)
POST/v1/environments/:env/oauth/device_authorizationOAuth clientRFC 8628 device authorization: { device_code, user_code, verification_uri, expires_in, interval }
POST/v1/environments/:env/oauth/tokenOAuth clientauthorization_code, device_code, client_credentials and token exchange (confidential clients only), refresh_token (identity gateway)
POST/v1/environments/:env/access/v1/evaluation, /access/v1/evaluations, /access/v1/search/subject, /access/v1/search/resource, /access/v1/search/actionADS callerThe hosted ADS, AuthZEN 1.0 (hosted ADS)
GET/v1/environments/:env/.well-known/authzen-configurationnoneAuthZEN metadata for the hosted ADS
POST/v1/environments/:env/ssf/receive/:connectorThe connector's streamRFC 8935 push from a caep-transmitter connector into the Cloud's SSF receiver
GET/v1/environments/:env/.well-known/ssf-configurationnoneThe Cloud's SSF 1.0 transmitter metadata (SsfTransmitterConfiguration)
GET, POST, PATCH, DELETE/v1/environments/:env/ssf/streamsSSF receiverSSF 1.0 stream management (SsfStreamConfiguration) for receivers of the Cloud's SETs
GET, POST/v1/environments/:env/ssf/statusSSF receiverA stream's SsfStreamStatus
POST/v1/environments/:env/ssf/verifySSF receiverAsks the Cloud to send a stream verification event
POST/v1/environments/:env/ssf/pollSSF receiverRFC 8936 poll delivery, setPollHandler over the Cloud's SetQueue
GET/v1/environments/:env/.well-known/jwks.json, /.well-known/openid-configurationnoneThe environment's JWK Set and issuer metadata; public
any/scim/v2/:connection/*The connection's bearerThe hosted SCIM endpoint an IdP provisions into; replayed to your scimHandler

The platform issuer and the projects read API sit at the root of PERMDOCK_CLOUD_URL, outside any environment (platform issuer):

MethodPathCredentialAnswer
GET/.well-known/oauth-authorization-servernoneRFC 8414 metadata; also at https://permdock.com/.well-known/oauth-authorization-server/api
GET/openapi.jsonnoneThis document, cloud-api-v1.json
GET/oauth/authorizenoneAuthorization code with PKCE, S256 only, loopback redirects for permdock-cli
POST/oauth/device_authorizationPublic clientRFC 8628 device authorization for permdock login
POST/oauth/tokenPublic clientauthorization_code with code_verifier, device_code and refresh_token
POST/oauth/revokePublic clientRFC 7009 revocation; 200 also for an unknown token
POST/oauth/registernoneRFC 7591 registration of an MCP client: 201 with client_id, 400 invalid_redirect_uri
GET/v1/projectsLogin with projects:read, or PERMDOCK_CLOUD_TOKENThe projects the credential reads
GET/v1/projects/:idLogin with projects:read, or PERMDOCK_CLOUD_TOKENOne project
GET/v1/projects/:id/policyLogin with policy:read, or PERMDOCK_CLOUD_TOKENThe project policy, or 404 policy-not-published

Environment variables

VariableSet byUsed for
PERMDOCK_CLOUD_URLMarketplace provisioning or youBase URL of the Cloud API and the platform issuer: https://permdock.com/api, the CLI's default
PERMDOCK_CLOUD_KEYMarketplace provisioning or youServer-side client key for the store, sink, snapshot, policy and catalog endpoints; rotated from the dashboard
PERMDOCK_CLOUD_ADMIN_KEYYou, from the dashboardadmin key for authoring scripts and the contract tests; never set in the application's runtime environment
PERMDOCK_CLOUD_ENVOptionalThe environment name; without it the Cloud names the key's environment. Required where module code calls cloudEndpoints()
PERMDOCK_CLOUD_TOKENYou, from the dashboard, in CIRead-only key pinned to one environment, for permdock cloud pull and rls generate --project without a login; never deployed with the application

permdock doctor reports whether the variables are present, whether the key reaches the API, and whether a client entry imports permdock/cloud.

Upgrading from 0.6: the client and permdock cloud push no longer read VERCEL_ENV or fall back to production. An app whose key belongs to the environment it ran against needs no change. An app that called cloudEndpoints() or read cloud().jwks at import without PERMDOCK_CLOUD_ENV sets it, or moves to cloud({ verifier: ({ jwks }) => … }) and await cloud().endpoints().

What stays local and what the Cloud adds

ConcernIn-process default (always available)With permdock/cloud
Decidingdecide on the bundled policyUnchanged
Pending approvalsmemoryApprovalStore()Persistent store, inbox UI scoped per tenant, delivery, approver groups, expiry jobs
Memberships and custom rolessubjectFrom*, context, your MembershipSource and CustomRoleSource; permdock/scim writing a DirectoryStore you ownA hosted SCIM relay per tenant (IdP setup wizard, group-to-role mapping UI, sync log) that replays provisioning to your scimHandler with an RFC 7523 bearer. In Cloud-native directory mode, the Cloud directory and assignment UI instead, delivered as claims on the tokens it issues. Either way the Cloud implements neither interface and is never read at request time, so it never joins the decision path (invariant 15); in bring-your-own mode it holds no authoritative copy
Evidence and governanceon('decision') handler, a DecisionSink over your table, permdock/otel for tracesDecision log with retention tiers; access-review, agent-activity, approval-chain and tenant queries over the principal, actor, tenant, membership and via fields of every event; signed (vnd.permdock.evidence+jwt), OCSF and CSV exports for auditors, SIEMs and compliance platforms; governance actions limited to rejecting pending approvals and invalidating snapshots (audit and observability "Evidence and governance")
SnapshotsServed by the application's endpoint, plain JSON or signed with your own TokenSignerEdge distribution, always signed (vnd.permdock.snapshot+jwt, keys at the environment's /.well-known/jwks.json), invalidated by CAEP
Non-TypeScript callersYour own permdock/authzen deploymentHosted AuthZEN ADS serving the same policy to gateways and services
Catalog and usagepermdock collect and permdock usage in CIDashboards over the same catalog files, including operations, and drift alerts
Parked runspollingApprovalWaiter({ store }) or a durable runner's waitercloudApprovalWaiter: woken by signed com.permdock.approval.* webhooks; the run still decides again with its token
DeliveryresendTransport, chatSdkTransport, webhookTransportcloudDeliveryTransport: the Cloud's hosted sender for recipients the router already checked, with com.permdock.delivery.* events back
SigningjoseTokenSigner with your own keycloudSigner: the environment key, for run tickets and other app-issued tokens; never policy, snapshot, evidence or approver tokens

Hosted Authorization Decision Service

The Cloud runs the same permdock/authzen handler your app could run, against the policy policyFromCatalog rebuilds from what you publish with permdock cloud push. The base is the environment URL: POST <env URL>/access/v1/evaluation, /access/v1/evaluations and /access/v1/search/*, with metadata at <env URL>/.well-known/authzen-configuration. Grants that run application code (check, a non-portable where) are pushed as closure: true and deny in the hosted ADS, so it never grants what the embedded engine would refuse. Kong, Envoy, Tyk, Zuplo or a Go service calls POST /access/v1/evaluation and gets an AuthZEN answer whose context carries outcome, denial reasons and the approval token (AuthZEN). Decision semantics are identical to the embedded engine because it is the same code; an evaluations batch of a policy matrix from permdock/testing is the parity test.

policyFromCatalog(catalog, pushed, { subject? }) from permdock/catalog is that rebuild, for any service that holds no application code. It throws PermDockValidationError when either input is malformed or the two disagree. What only code can answer fails closed:

  • An allow with a closure condition never grants, and a deny with one always applies.
  • A resource schema keyword outside the supported JSON Schema subset rejects every value.
  • A support role has no one who may consent.
  • Grant levels and OAuth scopes are not in a catalog, so the rebuilt policy has neither.

The grants a subject reaches are the instance's snapshot(), with no separate listing to keep in step. Build the instance from the rebuilt policy, pass the subject itself (the default subject of a rebuilt policy is the identity), and merge the live hosted grants through policies:

import { getPermDockSync, hostedPolicies, memoryPolicySource } from "permdock";
import { policyFromCatalog } from "permdock/catalog";

const policy = policyFromCatalog(catalog, pushed);
const permdock = getPermDockSync(policy, principal, {
  policies: hostedPolicies(memoryPolicySource(document)),
});
const { grants } = permdock.snapshot(); // code and hosted grants the subject reaches

A grant whose condition is application code is listed with portable: false; the rebuilt policy never grants through it, so a service that issues these grants leaves those entries out. Plans reach the catalog only when permdock collect finds them, so a hosted plan grant needs the plan in catalog.plans.

Caller authentication is real authentication (threat model, invariant 9):

  • On Vercel, the caller presents its OIDC token; the ADS verifies it with permdock/jwt against Vercel's JWKS and maps sub, project_id and environment to a workload principal.
  • Elsewhere, the caller uses OAuth client credentials issued per environment in the dashboard; the token's aud is the environment URL.
  • A request with no verifiable token is answered 401; a token for another environment is 403. There is no shared static secret mode.

The ADS never reads a subject from the request body unless the caller is registered as a trusted PEP for that environment, matching the in-app endpoint rule.

Vercel Marketplace

PermDock Cloud is listed as a native Marketplace integration so a Vercel project gets an environment, its variables and billing without leaving the dashboard.

  • Resource model. One Marketplace resource is one PermDock environment, created per Vercel project and environment (production, preview, development) so preview approvals never land in the production inbox. Provisioning writes only PERMDOCK_CLOUD_URL and PERMDOCK_CLOUD_KEY, a different key into each Vercel environment, and rotates them from either dashboard. It writes no PERMDOCK_CLOUD_ENV: the key names its environment through GET /v1/environment, so cloud() needs no VERCEL_ENV mapping. Module code that calls cloudEndpoints() at import still needs PERMDOCK_CLOUD_ENV.
  • Integration server. The Cloud repository implements the Marketplace provisioning API (install, resource create, update, delete, SSO, billing webhooks and invoices) per the approval checklist; this repository only documents the variables and the doctor check.
  • Template. apps/examples/eve-agent is the deploy template the listing requires: an Eve agent with post tools, permdock/eve approvals stored in the Cloud, and the inbox linked from the deployed app (Eve adapter).
  • Plans. A free environment with a short retention window, then usage-based plans on five meters. The Cloud reports each daily per resource through the Marketplace billing API (POST /v1/installations/{integrationConfigurationId}/billing, one usage item per meter with the resource's resourceId; Vercel asks for at least one submission a day). Decisions made by the embedded engine are never metered.
Meter idtypeCounts
active_principalstotalPrincipals with an event this period; a human and each agent actor once
connected_tenantstotalTenants with a SCIM connection or a tenant-scoped inbox
resolved_approvalsintervalApprovals resolved, above a free allowance
retention_tiertotalDays of decision retention the plan keeps
ads_evaluationsintervalHosted ADS evaluations; one per item of an evaluations batch

Each meter is reported per resource, so usage is per environment: a preview deployment's approvals and principals never count against production. Prices and allowances belong to the Cloud repository and the landscape research, not to the library docs.

MCP server

https://permdock.com/mcp is read-only. It is an OAuth protected resource whose RFC 9728 metadata, at https://permdock.com/.well-known/oauth-protected-resource/mcp, names the platform issuer; an MCP client registers there with RFC 7591 and signs the person in with PKCE. Its tools, each scoped to the environments the caller's token reaches:

ToolReads
search_decisionsThe decision log, filtered like the export
get_decisionOne decision event by id
list_approvalsApproval requests, filtered like approvals.list
get_approvalOne approval request by id
get_catalogThe latest pushed catalog
list_catalog_versionsCatalog versions with their fingerprints
list_drift_findingsDrift findings per version
list_hosted_grantsHosted grants and their state

There is no tool that resolves an approval, authors a grant or invalidates a snapshot.

Integrations

The Cloud has an Integrations page; what each connector speaks is fixed here and catalogued on Cloud integrations. Every connector is a standard wire format or an existing PermDock interface (trusted issuers over JWKS or OIDC Discovery, CAEP transmitters, SCIM sources, Vercel provisioning and OIDC, OTLP and OCSF export, CloudEvents webhooks, signed and CSV evidence exports, Chat SDK delivery, a read-only MCP server), and none sits on the decision path. No connector adds an npm entry.

Request lifecycle

  1. The application decides locally. Nothing on this path touches the network.
  2. on('decision') events go to permdockCloud.sink, which batches and posts them to POST /v1/environments/:env/decisions with the key.
  3. An approval-required outcome causes the adapter to call permdockCloud.approvals.create, which posts the ApprovalRequest to POST /v1/environments/:env/approvals. The Cloud notifies the configured approvers.
  4. An approver resolves the request in the inbox UI, through a Slack or Teams card (Chat SDK requestApproval; the platform signature identifies the responder) or through an email link that lands on the authenticated inbox. The Cloud authenticates the approver (its own session, the platform signature, or the application's approver token when approvals.resolve is called), applies the actor and distinct-approver rules, records resolvedBy.
  5. The application's retried call reads the record via permdockCloud.approvals.get, re-runs decide locally, compares the token and proceeds. A run parked on cloudApprovalWaiter is woken by the signed com.permdock.approval.resolved webhook first and then does the same. The resumed decision goes to the sink like any other.
  6. Independently of the above, an identity provider connected to the tenant's hosted SCIM endpoint pushes a provisioning operation. The Cloud records it in the sync log, applies the tenant's group-to-role mapping as the urn:ietf:params:scim:schemas:extension:permdock:2.0:Group attribute, and replays the operation to the application's scimHandler with an RFC 7523 JWT bearer signed by the environment key (iss = the environment URL, aud = your endpoint, tenant = the tenant, a 60-second exp). Your DirectoryStore is updated; the next createPermDock for an affected principal reads the change through directoryMembershipSource (SCIM adapter). If your endpoint is unreachable the Cloud retries with backoff and shows the failure in the sync log; nothing is decided from the Cloud's copy.

What it validates

  • The key is present and server-side; the entry throws at construction (not at decision time) when url or key is missing, so misconfiguration is visible at boot.
  • Responses from the API are validated against the wire-format versions this package knows; an unknown v is rejected and reported, and the adapter falls back to the in-memory store for that request so decisions never depend on the API being reachable.
  • Approver identity on the Cloud side comes from the Cloud's own authentication; the client never asserts who approved.

How denials surface

  • The Cloud is not on the decision path, so it produces no denials of its own. A resume against a request the Cloud reports as rejected or expired surfaces through the calling adapter exactly as with any store (approvals adapter).
  • API unreachable during a resume: denied with detail approval-not-found and an on('error') event; the application may retry.
  • The hosted ADS answers denied evaluations with decision: false and context.permdock.outcome plus denial reasons, and transport errors as Problem Details (AuthZEN adapter).

Example app

apps/examples/eve-agent (also the Marketplace template) uses cloud() for approvals and decisions when PERMDOCK_CLOUD_URL and PERMDOCK_CLOUD_KEY are set, applies hosted grants on its hostable post.publish through hostedPolicies(cloud().policies), and its README runs permdock cloud pull with PERMDOCK_CLOUD_TOKEN in CI. In apps/examples/authzen-pdp, src/pep-cloud.ts points the PEP at a hosted environment instead of the local PDP, and tests/parity.test.ts asserts identical decisions for the policy matrix and runs testCloudApi when the PERMDOCK_CLOUD_* variables are set. Its src/policy-cloud.ts rebuilds a project's pushed policy from the projects read API with policyFromCatalog, pnpm start:cloud serves the PDP over it, and tests/policy-cloud.test.ts asserts it decides like the code policy.

Why

  • A signed subject for GET /snapshot. A snapshot is authority, so the edge must know whose it serves. A query parameter or a key-wide subject would let any holder of the client key fetch anyone's snapshot; an at+jwt with a five-minute exp and a replay-checked jti proves one subject for one request, in either directory mode.
  • Signed approver facts. In bring-your-own mode only the application can compute holder() and relation(), because the relations and the policy live there. Signing them into the token the Cloud already verifies keeps the Cloud from trusting a resolve body (invariant 11), and a missing fact fails closed.
  • Trusted issuers, PEPs and approver groups as routes. Each is a different trust decision (whose tokens count, who may name a subject to the ADS, who gets notified), so each has its own resource instead of a connector kind. Approver groups route notifications and never make someone eligible.
  • A closed connector list. schemas/connector-v1.json lets the dashboard, the API and a self-hosted Cloud validate the same shapes; a new kind is a new branch and a changeset.
  • Invalidation never denies. POST /snapshots/invalidate shortens how long a cached snapshot lives; the next decision still runs in the application, so a governance action cannot become a remote deny.
  • The catalog answer carries findings. CI learns at push time which hosted grants a catalog breaks, from the same catalogDrift the Cloud runs, instead of from a later webhook.
  • One OAuth issuer, five grants. People get authorization code with PKCE, agents and CLIs the device flow, workloads client credentials, and services that already hold an upstream token exchange it; token exchange stays confidential-only because a public client could replay a stolen ID token.

Last updated on

On this page