# Cloud

Source: https://permdock.com/docs/adapters/cloud

permdock/cloud is the thin, optional client for PermDock Cloud; it implements ApprovalStore, DecisionSink and SnapshotSource 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:

| Host | Service |
| --- | --- |
| [app.permdock.com](https://app.permdock.com) | Dashboard and inbox |
| [api.permdock.com](https://api.permdock.com) | Machine API (`PERMDOCK_CLOUD_URL`) |
| [mcp.permdock.com](https://mcp.permdock.com) | Read-only MCP server |

## Identity gateway [#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 federates upstream IdPs through RFC 8693 token exchange (below); authorization code plus PKCE and the device flow for agents follow later. It maps Agent Auth capability grants onto permission references. The only OSS integration is `subjectFromJwt({ discovery: cloudEndpoints().issuer })`. 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 [#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](/docs/standards/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 `RoleSource`. 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.

```ts
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` | `[{ tenant, roles, team? }]` for declared `assignable` roles with `on: 'tenant'` or `'team'`, normalised to the [named scopes](/docs/concepts/scopes) form on read (the canonical `{ scope, id, within? }` form is planned for the Cloud) | `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, client authentication with the environment's OAuth client, `client_secret_basic`). It supports RFC 8693 token exchange and `refresh_token`:

| 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`](/docs/cli/cloud)), 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](/docs/adapters/ssf)) 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 [#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](/docs/concepts/wire-formats)) under the `policy` claim of a compact JWS with `typ: 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`. `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](/docs/concepts/policies) and the interface on [extension interfaces](/docs/concepts/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.

```ts
import { cloud, cloudEndpoints } from "permdock/cloud";
import { joseTokenVerifier } from "permdock/jwt";

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

const permdock = await createPermDock(policy, user, {
  policies: 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.

## Purpose [#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`](/docs/adapters/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](/docs/adapters/approvals), [audit](/docs/concepts/audit-and-observability)).

### Design rules [#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](/docs/adapters/pdp) 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 `RoleSource` 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 [#api]

```ts
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 VERCEL_ENV when present
});

// 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: permdockCloud.policies, // PolicySource: hosted grants on hostable permissions (optional)
  });
```

* `cloud()` returns a frozen object; `approvals`, `sink`, `snapshots` and `policies` are the interfaces, `issuer` is the environment URL `<PERMDOCK_CLOUD_URL>/v1/environments/<env>` and `jwks` is the environment's JWK Set URL. `cloudEndpoints()` returns the same `issuer` and `jwks` without a key, for building a verifier first. There is no `decide` on it.
* `verifyWebhook(request, { jwks, audience, replay })` and `parseCloudEvent` verify the Cloud's signed webhook deliveries ([Cloud integrations](/docs/adapters/cloud-integrations)); there is no unsigned mode.
* `policies` holds the last verified policy document ([hosted grants](#hosted-grants)). It needs `verifier` (a `TokenVerifier` over `jwks`); 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`. It posts the raw `SinkEvent` objects; the Cloud wraps them in CloudEvents envelopes ([wire formats](/docs/concepts/wire-formats)). A failed batch is re-queued, bounded by `capacity` (default 10 000, the same as `memorySink`), 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 (`permdock-decisions+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](/docs/concepts/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](/docs/adapters/approvals) "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.
* `snapshots` lets clients fetch a scoped snapshot from the Cloud edge instead of from the application server, and honours `session-revoked` and `credential-change` CAEP events received by the Cloud's SSF receiver ([Shared Signals](/docs/standards/shared-signals-caep)). Everything the Cloud serves is signed: a compact JWS with `typ: permdock-snapshot+jwt` and the unchanged Snapshot object under the `snapshot` claim ([wire formats](/docs/concepts/wire-formats)).

### Key kinds [#key-kinds]

Each environment issues three kinds of API key. None of them can sign anything or stand in for a subject:

| Kind | Variable | Reaches |
| --- | --- | --- |
| `client` | `PERMDOCK_CLOUD_KEY` | What the running application and CI call: `decisions`, `approvals`, `snapshot`, `policy` and `permdock cloud push` (`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 |

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.

### Keys [#keys]

The Cloud signs with a per-environment key through the `TokenSigner` interface ([extension interfaces](/docs/concepts/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().snapshots.get()` asks for `application/jwt` and returns only a compact `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: '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: permdock-decisions+jwt`) use the same key and endpoint ([audit and observability](/docs/concepts/audit-and-observability)). The private key never leaves the Cloud; the `PERMDOCK_CLOUD_KEY` API key is unrelated to it and cannot sign anything.

* 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 [#http-api]

The client speaks these paths under `PERMDOCK_CLOUD_URL`. In production that URL is `https://api.permdock.com`; 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 `VERCEL_ENV`, then `production`.

| Method | Path | Interface |
| --- | --- | --- |
| `POST` | `/v1/environments/:env/decisions` | `sink.write` / `sink.flush` body `{ events }` |
| `POST` | `/v1/environments/:env/approvals` | `approvals.create` |
| `GET` | `/v1/environments/:env/approvals/:token` | `approvals.get` (unreachable or unknown → `null`) |
| `POST` | `/v1/environments/:env/approvals/:token/resolve` | `approvals.resolve` (`409` not pending, `410` expired, `404` missing, `403` approver refused). A body `code` that names an `ApprovalError` code, with its `detail`, becomes the thrown error |
| `POST` | `/v1/environments/:env/approvals/:token/consume` | `approvals.consume`: 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` | `snapshots.get` with `accept: application/jwt`: a compact `permdock-snapshot+jwt` as the body; an unsigned JSON body throws |
| `POST` | `/v1/environments/:env/catalog` | `permdock cloud push`: body `{ fingerprint, catalog, policy? }` ([cloud](/docs/cli/cloud)); `200 { version, unchanged: true }` when the fingerprint is already published; not called by `cloud()` |
| `GET` | `/v1/environments/:env/policy` | `policies.refresh`: a `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](/docs/concepts/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` | `/v1/environments/:env/connectors` | `admin` | Integrations: `{ kind, name, config }`, for example `{ kind: 'webhook', name, config: { url, types } }` ([Cloud integrations](/docs/adapters/cloud-integrations)) |
| `GET` | `/v1/environments/:env/export?format=jws\|ocsf\|csv\|otlp` | `export` | The decision log as a `permdock-decisions+jwt`, OCSF (`toOcsf`), CSV (`toCsvRow`) or OTLP logs |
| `POST` | `/v1/environments/:env/oauth/token` | OAuth client | RFC 8693 token exchange and `refresh_token` ([identity gateway](#identity-gateway)) |
| `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` |

### Environment variables [#environment-variables]

| Variable | Set by | Used for |
| --- | --- | --- |
| `PERMDOCK_CLOUD_URL` | Marketplace provisioning or you | Base URL of the environment's API. Production host is `https://api.permdock.com` |
| `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 | Overrides the environment name when `VERCEL_ENV` is absent |

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

## What stays local and what the Cloud adds [#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 `RoleSource`; [`permdock/scim`](/docs/adapters/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 (`permdock-decisions+jwt`), OCSF and CSV exports for auditors, SIEMs and compliance platforms; governance actions limited to rejecting pending approvals and invalidating snapshots ([audit and observability](/docs/concepts/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 (`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, drift alerts |

## Hosted Authorization Decision Service [#hosted-authorization-decision-service]

The Cloud runs the same `permdock/authzen` handler your app could run, against the policy you publish with [`permdock cloud push`](/docs/cli/cloud). 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](/docs/standards/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.

Caller authentication is real authentication ([threat model](/docs/security/threat-model), invariant 9):

* On Vercel, the caller presents its [OIDC token](https://vercel.com/docs/oidc); 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 [#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. `PERMDOCK_CLOUD_URL` and `PERMDOCK_CLOUD_KEY` are written to the project's environment variables on provisioning and rotated from either dashboard.
* **Integration server.** The Cloud repository implements the Marketplace provisioning API (install, resource create, update, delete, SSO, billing webhooks and invoices) per the [approval checklist](https://vercel.com/docs/integrations/create-integration/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](/docs/adapters/eve)).
* **Plans.** A free environment with a short retention window, then usage-based plans on five meters: monthly active principals (a human and each agent `actor` counted once), connected tenants (a tenant with a SCIM connection or a tenant-scoped inbox), resolved approvals above a free allowance at a small per-unit price, the decision retention tier, and hosted ADS evaluations. Decisions made by the embedded engine are never metered. The exact numbers belong to the Cloud repository and the [landscape](/docs/research/landscape) research, not to the library docs.

## Integrations [#integrations]

The Cloud has an Integrations page; what each connector speaks is fixed here and catalogued on [Cloud integrations](/docs/adapters/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 [#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, 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. 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:permdock:scim:schemas:extension:roles:1.0` 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](/docs/adapters/scim)). 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 [#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 [#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](/docs/adapters/approvals)).
* 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](/docs/adapters/authzen)).

## Example app [#example-app]

`apps/examples/eve-agent` (also the Marketplace template) and a `cloud` variant of `apps/examples/authzen-pdp` in which the PEP process points at a hosted environment instead of the local Hono PDP, asserting identical decisions for the policy matrix.

## Related standards [#related-standards]

* [AuthZEN](/docs/standards/authzen): the hosted ADS wire format.
* [Shared Signals and CAEP](/docs/standards/shared-signals-caep): snapshot invalidation.
* [SCIM 2.0](/docs/standards/scim): the hosted relay's protocol and the RFC 7523 bearer it presents to `scimHandler`.
* [Wire formats](/docs/concepts/wire-formats): `ApprovalRequest`, `DecisionEvent`, the snapshot and the signed-output envelope.
* [JOSE](/docs/standards/jose) and the [JWT adapter](/docs/adapters/jwt): the `TokenSigner` and `TokenVerifier` the Cloud signs and clients verify with.
* [Approvals adapter](/docs/adapters/approvals) and [Audit and observability](/docs/concepts/audit-and-observability): the interfaces this entry implements.
