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:
| URL | Service |
|---|---|
| app.permdock.com | Dashboard and inbox |
| permdock.com/api | Machine API (PERMDOCK_CLOUD_URL) and the platform issuer |
| permdock.com/mcp | Read-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 base | Versioned | Why |
|---|---|---|
/.well-known/oauth-authorization-server, /oauth/*, /openapi.json | No | The base is the OAuth issuer, and RFC 8414 and RFC 6749 fix where its metadata and endpoints live |
/v1/projects, /v1/environments/:env/* | /v1 | PermDock'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 athttps://permdock.com/api/.well-known/oauth-authorization-server. Itsissuerishttps://permdock.com/api, and a client refuses metadata with any otherissuer. - Endpoints.
/oauth/authorize(authorization code with PKCE,S256only, including the RFC 8252 loopback redirecthttp://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/registeras a public client (token_endpoint_auth_method: "none") with loopback orhttpsredirect URIs; the Cloud MCP server names the issuer in its RFC 9728 metadata athttps://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
S256and astateit checks on the callback. It asks forprojects:readandpolicy:read, so it can list projects and read their catalog and manifest but not push. Its redirect URIs are onhttps://bettersupabase.com,https://www.localhostfor 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 arename,slugandupdatedAtfromGET /v1/projects. - Scopes.
projects:readlists and reads the person's projects,policy:readreads a project's pushed catalog, manifest and policy,catalog:writeletspermdock cloud pushpublish with a login instead ofPERMDOCK_CLOUD_KEY,evidence:readreads a project's decision log and exports, andapprovals:readreads its approval requests and verdicts.evidence:readandapprovals:readare read-only: no scope lets a login write decisions or resolve an approval. - Revocation.
/oauth/revokeanswers200for a revoked token and for one it does not know (RFC 7009 section 2.2);permdock logoutsends the refresh token. - Challenges. A refused token answers
401withWWW-Authenticate: Bearer realm="permdock", error="invalid_token". A token without the route's scope answers403witherror="insufficient_scope"and the missingscope(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.
| Route | Scope | Answer |
|---|---|---|
GET /v1/projects | projects:read | { items: [{ id, name, slug?, environment, issuer?, catalog?, updatedAt? }], next? }; with PERMDOCK_CLOUD_TOKEN, the one project it reads |
GET /v1/projects/:id | projects:read | One project; catalog is { version, fingerprint } of the last push |
GET /v1/projects/:id/policy | policy:read | The 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.stateis 16 random bytes and the verifier 32, both base64url.exchangeCode(callback, { state, codeVerifier })throwsstate-mismatchunless the callback echoesstate,issuer-mismatchfor an RFC 9207issof another server, andauthorization-deniedfor anerroror a missingcode.exchangeCodeandrefresh(refreshToken)return aCloudCredentialwith RFC 6749 names:access_token,token_type,refresh_token,scopeandexpires_atin seconds since the epoch. A refused grant throwstoken-refusedwith the server'serror.listProjects(accessToken, { cursor? })returns{ items, next? }ofCloudProject, checked against theProjectschema; a list that does not match throwsunexpected-response.clientSecretmakes a confidential client that authenticates withclient_secret_basic. Without it the client is public and sendsclient_idin the body.- Every failure is a
CloudAuthErrorwith acode:discovery-failed,state-mismatch,issuer-mismatch,authorization-denied,token-refused,invalid-token,insufficient-scope(withscope),forbidden,rate-limited,unreachableorunexpected-response.statusanderrorcarry what the Cloud sent.isCloudAuthErrormatches bynameandcode. - 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
keyorscopevalues, never a second set of names. - The library still does not authenticate. Core never verifies a token;
permdock/jwtverifies the Cloud's token exactly as it verifies any other issuer's, and there is nopermdock/cloud-authentry. A Cloud outage leaves the embedded engine deciding from the application's ownsubjectFrom*and tokens already issued.
Directory modes
Each environment picks one directory mode:
| Mode | Directory of record | How facts reach decide | Cloud outage |
|---|---|---|---|
| Cloud-native | The Cloud: users, groups, memberships, held declared roles and plans | Claims on the Cloud-issued access token (roles, groups, entitlements, the tenant claim per JWT authorization claims), verified by subjectFromJwt in your app | New logins fail; issued tokens keep deciding until they expire |
| Bring your own | Your tables, Clerk, Okta, Entra ID or Better Auth | The provider's token through its subjectFrom*, and your DirectoryStore / MembershipSource, fed by the SCIM relay when you use it | Nothing 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 token | Carries | Read as |
|---|---|---|
sub | The Cloud user id | principal.id |
roles | Declared global roles (no on) the principal holds in the active tenant, from assignments plus group memberships at issue time | principal.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? }] form | principal.memberships |
The tenant claim (org_id unless the environment configures another name) | The active tenant of the exchange | The active tenant (claims.tenant), kept only when a membership matches it |
groups | Directory group ids | Team memberships through groupRoles or a MembershipSource |
entitlements | Plans held | principal.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_type | Client | Input |
|---|---|---|
authorization_code | Public or confidential | The 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_code | Public (an agent, a CLI) | The device_code from POST /oauth/device_authorization (RFC 8628) once the person approved the user_code |
client_credentials | Confidential 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-exchange | Confidential only (client_secret_basic) | An upstream ID token, below |
refresh_token | The client the refresh token was issued to | A refresh token; rotated on each use |
Token exchange takes:
| Parameter | Value |
|---|---|
grant_type | urn:ietf:params:oauth:grant-type:token-exchange |
subject_token | An ID token from a trusted-issuer connector; the Cloud verifies it with permdock/jwt and maps its sub to a directory principal |
subject_token_type | urn:ietf:params:oauth:token-type:id_token |
audience | Your application; becomes the access token's aud |
tenant | Optional: 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 collectproduces, never accepts a free-text role, and only hands out roles the policy declaresassignable, 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-revokedorcredential-changefor 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
membershipevent withsource: 'cloud'andbyset 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 thepolicyclaim of a compact JWS withtyp: vnd.permdock.policy+jwt, signed by the environment key.cloud().policies.refresh()fetches it, verifies it with theverifieryou pass tocloud()(checkingtyp,exp, andissandaudagainst the environment URL<PERMDOCK_CLOUD_URL>/v1/environments/<env>), and keeps it. The Cloud setsexpto 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 averifierthe source never holds a document. cloud().policiesis aPolicySource; pass it aspolicies: hostedPolicies(permdockCloud.policies).createPermDockreads itscurrent()document once per instance; the application callsrefresh()on its own schedule (an interval, a cron, or thecatalogwebhook), 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 anapprovala 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 theHostedGrantDropReasonmergeHostedGrantswould report (not-hostable,unknown-permission,unknown-grantee,non-portable,weaker-approval,invalid). It needs noPolicyDocument.hostedGrantFindingsis not a substitute: it reports drift between catalogs, so it skipsnon-portableandinvalid, which a catalog change cannot cause.hostedGrantOptions(policy)lists, perhostablepermission, what a grant may say:grantees.roles(declared roles grouped by the scope they are held at,globalfor a role with noon),grantees.plans,grantees.relations(withthrough: 'parent'on a self-parented resource),approval(requiredandmeetswhen a code allow carries an approval,modeshumanorany, the object-formfields,staleOnonly on a resource with aversion, and the approver kinds) andconditions(the portable subset, over the resource's schemafields).
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/cloudpackage, so consumers and thepermdock-wireskill 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 separatePermDock-Cloudrepository 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-rundecideand recompute the token before trusting a stored approval. - Every event is evidence. Sinks receive every
granted,deniedandapproval-requiredevent; 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
CustomRoleSourceorcontextreads. - A relay, not a membership source. The hosted SCIM endpoint replays provisioning into a
DirectoryStoreyour application owns, so self-hosters get the same feature frompermdock/scimand 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,sinkandpoliciesare the interfaces,snapshotsFor(token)returns oneSnapshotSourceper subject,catalogpublishes the catalog,endpoints()resolves the environment URL<PERMDOCK_CLOUD_URL>/v1/environments/<env>asissuerand the environment's JWK Set URL asjwks. The fieldsissuerandjwksare set at construction only when the environment is configured.cloudEndpoints()returns the sameissuerandjwkssynchronously, without a key, and throws when neitherenvironmentnorPERMDOCK_CLOUD_ENVis set;await resolveCloudEndpoints()asks the Cloud with the key instead.urldefaults toPERMDOCK_CLOUD_URL, thenCLOUD_BASE_URL. There is nodecideon it.verifyWebhook(request, { verifier })(or{ jwks, secret, replay }) andparseCloudEventverify the Cloud's Standard Webhooks deliveries of CloudEvents batches (Cloud integrations); there is no unsigned mode.policiesholds the last verified policy document (hosted grants). It needsverifier: aTokenVerifieroverjwks, or a functioncloud()calls once with the resolved endpoints, so the environment need not be known at import; the expectedissandaudare the environment URL, so there is no audience to configure. Without a verifiercurrent()staysnull.sinkbatches events in memory and flushes on size (flushAt) and at the end of a request when the runtime exposeswaitUntil. Each flush posts a CloudEvents JSON batch withContent-Type: application/cloudevents-batch+json: an array holding onetoCloudEvent(event, { source })envelope perSinkEvent, wheresourceiscloud({ source }), an absolute URI that defaults to the environment URL (CloudEvents, wire formats). A failed batch is re-queued, bounded bycapacity(default 10 000, the same asmemoryDecisionSink), 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 throughon('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 acceptpermdock/otelspans: 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).approvalsspeaks the sameApprovalRequestwire shape as the in-memory store (v: 1, with the optionalapproversandsubject.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.resolveproves who resolved with aPermDock-Approvertoken signed byapprover.signer(approver token).snapshotsFor(token)lets clients fetch a snapshot from the Cloud edge instead of from the application server.tokenis anat+jwtnaming the subject, sent as thePermDock-Subjectheader: one the environment issued (Cloud-native mode,audincludes the environment URL), or one the application signed with a trusted issuer registered for purposesubject(bring-your-own mode). In both,expis at most five minutes afteriatand the Cloud refuses ajtiit has seen, the checksverifySubjectTokeninpermdock/jwtmakes (JWT adapter); the Cloud never takes a subject from a query string or a body. It honourssession-revokedandcredential-changeCAEP events received by the Cloud's SSF receiver (Shared Signals). Everything the Cloud serves is signed: a compact JWS withtyp: vnd.permdock.snapshot+jwtand the unchanged Snapshot object under thesnapshotclaim (wire formats).catalog.push(catalog)posts aCatalogDocumentand itscatalogFingerprinttoPOST /catalogand returns what the Cloud answers:{ version, fingerprint, findings }for a new version (201, the drift findings against the previous one, ascatalogDriftcomputes them), or{ version, fingerprint, unchanged: true }(200). A non-2xx status or another shape throws. The document carriesoperationswhen it was built with an operations module, so the Cloud's dashboards and drift checks name the operations bound to each permission.permdock cloud pushis 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",
}),
},
});| Field | Value |
|---|---|
typ | at+jwt (RFC 9068) |
iss | The signer's issuer |
sub | verdict.by.principal.id |
aud | approver.audience, default <issuer>/approvals with issuer the environment URL (cloud().issuer) |
iat | The signing time |
exp | iat plus 60 seconds |
jti | A fresh UUID per resolve; the Cloud refuses a jti it has seen |
act | Never present |
roles, memberships, tenant | verdict.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: theholder(permission)approvers the person holds, fromapprover.permissions(request, by), typicallyapproverPermissions(request, permdockFor(by)); without the option,verdict.permissions.relations: the relation approvers the person holds on the request's resource, fromapprover.relations(request, by), typicallyapproverRelations(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).submust equal the approver'ssubjectin the Cloud directory, and a registered audience must equalaud. resolvethrowsApprovalErrorbefore sending a resolve when it cannot prove a person. The codes areapprover-unauthenticated(noapproveroption, an anonymous principal, an empty id, or a signer that throws) andapprover-not-eligible(a verdict whose subject has anactor, which is an agent approving). A failed read of the request throws the code the Cloud names,approval-not-foundby default.- A system rejection carries no header, because no person resolved it. That is
status: 'rejected'withby.actor.kind: 'system', whichcancelApprovalsand expiry send. cloudSignerrefusesat+jwt. A token signed with the environment key would let any holder of theclientkey 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.
| Client | Implements | In-process default |
|---|---|---|
cloudApprovalWaiter({ store | waiter }) | ApprovalWaiter and a webhook | pollingApprovalWaiter({ store }) |
cloudDeliveryTransport(channel) | DeliveryTransport<channel> | resendTransport, chatSdkTransport, webhookTransport |
cloudSigner({ audience }) | TokenSigner | joseTokenSigner |
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" });cloudApprovalWaiterparks a run until the Cloud reports its approval.webhook(request)verifies the Standard Webhooks delivery withverifyWebhookand callswake(token)for eachcom.permdock.approval.resolved,.expiredand.cancelledevent. It answers204once handled or replayed, and Problem Details otherwise:400for missing or malformed signature headers, a stale timestamp or a body that is not a PermDock CloudEvents batch,403for a signature that does not verify, and503when it cannot fetch the signing keys. It never answers401, because a webhook has no authentication scheme to name in theWWW-Authenticateheader RFC 9110 requires with it. Withstore, a wait also polls the store every 60 seconds, for a webhook another process received. Withwaiter,waitandwakego to a durable runner's waiter.approvalTokensOf(events)is the token extraction on its own, for a runner that receives the webhook elsewhere.cloudDeliveryTransportposts each recipient the router already checked toPOST /deliveries: the channel, the delivery id, the address, the principal and the permission that let it through, and the message. TheIdempotency-Keyheader is the router's per-recipient key, so a retry repeats it. A target that failsisDeliveryTargetis refused asinvalid-recipientwithout a request. Its statuses map as follows:429israte-limitedand408,409and5xxareunavailable(all retryable);401and403areunauthorized,400and422areinvalid-message, and anything else isrejected(all final). Delivery outcomes come back ascom.permdock.delivery.*events.cloudSignersigns with the environment's key, typically KMS-held, throughPOST /sign, and the response is a compact JWS whosetypmust match the request. It is meant for run tickets (vnd.permdock.run+jwt), approval, capability and support-session tokens. It refusesvnd.permdock.policy+jwt,vnd.permdock.snapshot+jwtandvnd.permdock.evidence+jwt, because those are the Cloud's own outputs and a client key that could mint them could forge them. It also refusesat+jwt, the approver token, which must come from the application's own key. It requires an audience. The Cloud setsissto the environment URL and dropsiss,aud,exp,iat,nbfandjtifrom 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:
| Kind | Variable | Reaches |
|---|---|---|
client | PERMDOCK_CLOUD_KEY | What the running application and CI call: decisions, approvals, snapshot, policy, deliveries, sign and POST /catalog |
admin | PERMDOCK_CLOUD_ADMIN_KEY | Authoring: hosted grants, directory principals, groups and assignments, connectors. Never deployed with the application |
export | Set per export destination | GET /export only |
read | PERMDOCK_CLOUD_TOKEN | One 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
fetchand the wire formats from core. Each request is aborted after 10 seconds, combined with anysignalyou pass, and then fails like an unreachable Cloud. It is server-only andtests/bundleasserts 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.
| Method | Path | Interface |
|---|---|---|
GET | /v1/environment | endpoints() 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/decisions | sink.write / sink.flush: a CloudEvents JSON batch, Content-Type: application/cloudevents-batch+json, one toCloudEvent envelope per event |
POST | /v1/environments/:env/approvals | approvals.create |
GET | /v1/environments/:env/approvals/by-token | approvals.get: the token in the PermDock-Approval header, never in the URL (unreachable or unknown → null) |
GET | /v1/environments/:env/approvals/:id | approvals.getById (unreachable or unknown → null) |
POST | /v1/environments/:env/approvals/by-token/resolve | approvals.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/consume | approvals.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/approvals | approvals.list: ?status=, principalId, actorId, tenant, session, limit, cursor; returns { items, next? } (unreachable → { items: [] }) |
POST | /v1/environments/:env/approvals/expire | approvals.expire |
POST | /v1/environments/:env/approvals/cancel | approvals.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/snapshot | snapshotsFor(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/catalog | permdock 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/deliveries | cloudDeliveryTransport: body { channel, delivery, target: { address, principal?, provider?, workspace?, trusted?, permission }, message } with an Idempotency-Key header; 2xx { id } once accepted |
POST | /v1/environments/:env/sign | cloudSigner: 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/policy | policies.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):
| Method | Path | Key | Purpose |
|---|---|---|---|
GET | /v1/environments/:env/catalog, /catalog/versions | client | The latest catalog and its versions |
GET, POST | /v1/environments/:env/hosted-grants | admin | List ({ 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/:id | admin | Remove a hosted grant; 204. Every change issues a new policy document |
GET, POST | /v1/environments/:env/directory/principals, /directory/groups | admin | Cloud-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] | admin | Role 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] | admin | Integrations: { 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] | admin | Callers 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/invalidate | admin | { 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|otlp | export | The decision log as a vnd.permdock.evidence+jwt, OCSF (toOcsf), CSV (toCsvRow) or OTLP logs |
GET | /v1/environments/:env/oauth/authorize | none | Authorization code with PKCE; code_challenge_method=S256 required, plain refused (identity gateway) |
POST | /v1/environments/:env/oauth/device_authorization | OAuth client | RFC 8628 device authorization: { device_code, user_code, verification_uri, expires_in, interval } |
POST | /v1/environments/:env/oauth/token | OAuth client | authorization_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/action | ADS caller | The hosted ADS, AuthZEN 1.0 (hosted ADS) |
GET | /v1/environments/:env/.well-known/authzen-configuration | none | AuthZEN metadata for the hosted ADS |
POST | /v1/environments/:env/ssf/receive/:connector | The connector's stream | RFC 8935 push from a caep-transmitter connector into the Cloud's SSF receiver |
GET | /v1/environments/:env/.well-known/ssf-configuration | none | The Cloud's SSF 1.0 transmitter metadata (SsfTransmitterConfiguration) |
GET, POST, PATCH, DELETE | /v1/environments/:env/ssf/streams | SSF receiver | SSF 1.0 stream management (SsfStreamConfiguration) for receivers of the Cloud's SETs |
GET, POST | /v1/environments/:env/ssf/status | SSF receiver | A stream's SsfStreamStatus |
POST | /v1/environments/:env/ssf/verify | SSF receiver | Asks the Cloud to send a stream verification event |
POST | /v1/environments/:env/ssf/poll | SSF receiver | RFC 8936 poll delivery, setPollHandler over the Cloud's SetQueue |
GET | /v1/environments/:env/.well-known/jwks.json, /.well-known/openid-configuration | none | The environment's JWK Set and issuer metadata; public |
| any | /scim/v2/:connection/* | The connection's bearer | The 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):
| Method | Path | Credential | Answer |
|---|---|---|---|
GET | /.well-known/oauth-authorization-server | none | RFC 8414 metadata; also at https://permdock.com/.well-known/oauth-authorization-server/api |
GET | /openapi.json | none | This document, cloud-api-v1.json |
GET | /oauth/authorize | none | Authorization code with PKCE, S256 only, loopback redirects for permdock-cli |
POST | /oauth/device_authorization | Public client | RFC 8628 device authorization for permdock login |
POST | /oauth/token | Public client | authorization_code with code_verifier, device_code and refresh_token |
POST | /oauth/revoke | Public client | RFC 7009 revocation; 200 also for an unknown token |
POST | /oauth/register | none | RFC 7591 registration of an MCP client: 201 with client_id, 400 invalid_redirect_uri |
GET | /v1/projects | Login with projects:read, or PERMDOCK_CLOUD_TOKEN | The projects the credential reads |
GET | /v1/projects/:id | Login with projects:read, or PERMDOCK_CLOUD_TOKEN | One project |
GET | /v1/projects/:id/policy | Login with policy:read, or PERMDOCK_CLOUD_TOKEN | The project policy, or 404 policy-not-published |
Environment variables
| Variable | Set by | Used for |
|---|---|---|
PERMDOCK_CLOUD_URL | Marketplace provisioning or you | Base URL of the Cloud API and the platform issuer: https://permdock.com/api, the CLI's default |
PERMDOCK_CLOUD_KEY | Marketplace provisioning or you | Server-side client key for the store, sink, snapshot, policy and catalog endpoints; rotated from the dashboard |
PERMDOCK_CLOUD_ADMIN_KEY | You, from the dashboard | admin key for authoring scripts and the contract tests; never set in the application's runtime environment |
PERMDOCK_CLOUD_ENV | Optional | The environment name; without it the Cloud names the key's environment. Required where module code calls cloudEndpoints() |
PERMDOCK_CLOUD_TOKEN | You, from the dashboard, in CI | Read-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
| Concern | In-process default (always available) | With permdock/cloud |
|---|---|---|
| Deciding | decide on the bundled policy | Unchanged |
| Pending approvals | memoryApprovalStore() | Persistent store, inbox UI scoped per tenant, delivery, approver groups, expiry jobs |
| Memberships and custom roles | subjectFrom*, context, your MembershipSource and CustomRoleSource; permdock/scim writing a DirectoryStore you own | A 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 governance | on('decision') handler, a DecisionSink over your table, permdock/otel for traces | Decision 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") |
| Snapshots | Served by the application's endpoint, plain JSON or signed with your own TokenSigner | Edge distribution, always signed (vnd.permdock.snapshot+jwt, keys at the environment's /.well-known/jwks.json), invalidated by CAEP |
| Non-TypeScript callers | Your own permdock/authzen deployment | Hosted AuthZEN ADS serving the same policy to gateways and services |
| Catalog and usage | permdock collect and permdock usage in CI | Dashboards over the same catalog files, including operations, and drift alerts |
| Parked runs | pollingApprovalWaiter({ store }) or a durable runner's waiter | cloudApprovalWaiter: woken by signed com.permdock.approval.* webhooks; the run still decides again with its token |
| Delivery | resendTransport, chatSdkTransport, webhookTransport | cloudDeliveryTransport: the Cloud's hosted sender for recipients the router already checked, with com.permdock.delivery.* events back |
| Signing | joseTokenSigner with your own key | cloudSigner: 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 reachesA 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/jwtagainst Vercel's JWKS and mapssub,project_idandenvironmentto a workload principal. - Elsewhere, the caller uses OAuth client credentials issued per environment in the dashboard; the token's
audis the environment URL. - A request with no verifiable token is answered
401; a token for another environment is403. 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 onlyPERMDOCK_CLOUD_URLandPERMDOCK_CLOUD_KEY, a different key into each Vercel environment, and rotates them from either dashboard. It writes noPERMDOCK_CLOUD_ENV: the key names its environment throughGET /v1/environment, socloud()needs noVERCEL_ENVmapping. Module code that callscloudEndpoints()at import still needsPERMDOCK_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
doctorcheck. - Template.
apps/examples/eve-agentis the deploy template the listing requires: an Eve agent withposttools,permdock/eveapprovals 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, oneusageitem per meter with the resource'sresourceId; Vercel asks for at least one submission a day). Decisions made by the embedded engine are never metered.
| Meter id | type | Counts |
|---|---|---|
active_principals | total | Principals with an event this period; a human and each agent actor once |
connected_tenants | total | Tenants with a SCIM connection or a tenant-scoped inbox |
resolved_approvals | interval | Approvals resolved, above a free allowance |
retention_tier | total | Days of decision retention the plan keeps |
ads_evaluations | interval | Hosted 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:
| Tool | Reads |
|---|---|
search_decisions | The decision log, filtered like the export |
get_decision | One decision event by id |
list_approvals | Approval requests, filtered like approvals.list |
get_approval | One approval request by id |
get_catalog | The latest pushed catalog |
list_catalog_versions | Catalog versions with their fingerprints |
list_drift_findings | Drift findings per version |
list_hosted_grants | Hosted 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
- The application decides locally. Nothing on this path touches the network.
on('decision')events go topermdockCloud.sink, which batches and posts them toPOST /v1/environments/:env/decisionswith the key.- An
approval-requiredoutcome causes the adapter to callpermdockCloud.approvals.create, which posts theApprovalRequesttoPOST /v1/environments/:env/approvals. The Cloud notifies the configured approvers. - 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 whenapprovals.resolveis called), applies the actor and distinct-approver rules, recordsresolvedBy. - The application's retried call reads the record via
permdockCloud.approvals.get, re-runsdecidelocally, compares the token and proceeds. A run parked oncloudApprovalWaiteris woken by the signedcom.permdock.approval.resolvedwebhook first and then does the same. The resumed decision goes to the sink like any other. - 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:Groupattribute, and replays the operation to the application'sscimHandlerwith an RFC 7523 JWT bearer signed by the environment key (iss= the environment URL,aud= your endpoint,tenant= the tenant, a 60-secondexp). YourDirectoryStoreis updated; the nextcreatePermDockfor an affected principal reads the change throughdirectoryMembershipSource(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
urlorkeyis missing, so misconfiguration is visible at boot. - Responses from the API are validated against the wire-format versions this package knows; an unknown
vis 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
rejectedorexpiredsurfaces through the calling adapter exactly as with any store (approvals adapter). - API unreachable during a resume:
deniedwithdetailapproval-not-foundand anon('error')event; the application may retry. - The hosted ADS answers denied evaluations with
decision: falseandcontext.permdock.outcomeplus 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 theclientkey fetch anyone's snapshot; anat+jwtwith a five-minuteexpand a replay-checkedjtiproves one subject for one request, in either directory mode. - Signed approver facts. In bring-your-own mode only the application can compute
holder()andrelation(), 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.jsonlets 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/invalidateshortens 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
catalogDriftthe 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.
Related standards
- AuthZEN: the hosted ADS wire format.
- Shared Signals and CAEP: snapshot invalidation.
- SCIM 2.0: the hosted relay's protocol and the RFC 7523 bearer it presents to
scimHandler. - Wire formats:
ApprovalRequest,DecisionEvent, the snapshot and the signed-output envelope. - JOSE and the JWT adapter: the
TokenSignerandTokenVerifierthe Cloud signs and clients verify with. - Approvals adapter and Audit and observability: the interfaces this entry implements.
Last updated on
Workflow SDK
permdock/workflow-sdk runs PermDock automations on the Workflow SDK. workflowSdkWaiter parks a run on a hook while an approval is pending, startAutomation starts the PermDock run and the Workflow SDK run together, and authorizeHook resumes a hook only for a caller with the permission.
Cloud integrations
The connectors PermDock Cloud offers on its Integrations page, what each one speaks and what it never does; every connector is a standard wire format or an existing PermDock interface, none is an npm entry, and none sits on the decision path.