# Platform API

A platform is a business that serves its own users through Mehen ([Platforms and managed workspaces](https://docs.mehen.dev/concepts/platforms.md)). It calls the vault at `https://app.mehen.dev`, under `/v1/platform`, with JSON in and out. [Build on Mehen](https://docs.mehen.dev/guides/build-on-mehen.md) walks through the calls in order; `@mehen/client`'s platform client makes every one of them for you. The tables on this page are generated from the route table in Mehen's protocol, so they say what the vault named in the footer accepts.

## Signing a call

Every call carries a `Mehen-Platform-Proof` header: a compact JWS, signed with the platform's own P-256 key, over that one request. There is no bearer token, so nothing on the platform's disk or in a log can be replayed.

```text
header  { "alg": "ES256", "typ": "mehen-platform+jwt", "kid": "pky_..." }
payload { "plt": "plt_...", "sub": "<subject>", "htm": "POST", "htu": "https://app.mehen.dev/v1/platform/...", "bh": "<base64url SHA-256 of the body>", "iat": 1793270400, "jti": "<random>" }
```

| Claim | What it is |
| --- | --- |
| `kid` | The platform key's id, `pky_...`, which Mehen's operator gave the platform when it registered the key. |
| `plt` | The platform's id, `plt_...`. It must be the key's own platform. |
| `sub` | The subject the path names, on every route under `/v1/platform/subjects/:subject`, and absent on every other route. It must equal the path's subject. |
| `htm`, `htu` | The method, and the full URL with its query. |
| `bh` | The base64url SHA-256 of the exact body bytes, or of nothing for a request with no body. |
| `iat` | When it was signed, in seconds. The vault accepts 60 seconds either way. |
| `jti` | A random nonce. The vault accepts each one once. |

The proof's `typ` and header differ from a machine's `Mehen-Proof`, so neither can stand in for the other, and a platform call that also carries `Mehen-Proof` is refused with `bad_request`. The vault checks the proof in this order: the key by its id, with no cache; the signature, method, URL, body hash, time and subject; the platform's status; then the nonce, spent once per key, and the platform's limits. [Keys, proofs and signing](https://docs.mehen.dev/security/keys-and-signing.md#platform-proofs) has the details.

## Subjects

A **subject** is the platform's own name for one of its users: 8 to 128 of `A-Z a-z 0-9 _ -`. Mehen never interprets it, and it is unique within its platform only: the vault finds a workspace from the verified platform and the subject together, so another platform naming the same string reaches its own workspace or none. Choose a subject that says nothing about the person, and never reuse one.

## Answers and refusals

A success is the JSON the route's table gives. Answers may gain members in later releases; read what you know and ignore the rest. No answer carries a secret value, a client secret, a token, a link to a Mehen page or a command to run.

A refusal is the error object of [Errors and exit codes](https://docs.mehen.dev/reference/errors.md), with the header `Mehen-Error: 1` and the HTTP status that page gives its code. Two codes are only ever a platform's: [`platform_suspended`](https://docs.mehen.dev/reference/errors.md#platform_suspended) and [`quota_exceeded`](https://docs.mehen.dev/reference/errors.md#quota_exceeded).

In a managed workspace, a refusal a machine gets on the ordinary machine routes carries no link: `approval_required` names the approval and its binding code for the platform to decide, and `needs_reauth` says the platform reconnects the account. [HTTP surfaces](https://docs.mehen.dev/reference/http.md#managed-workspaces) lists how each machine route answers there.

## Routes

Every route is under `/v1/platform` and carries `Mehen-Platform-Proof`. In the tables below, a nested member such as `a.b` is required within its parent, `a[].b` is a member of each item of the array `a`, and `a.<name>.b` a member of each value of the map `a`. The platform's own routes:

| Route | Method and path | What it does |
| --- | --- | --- |
| [`platform.view`](#platformview) | `GET /v1/platform` | Reads the platform as Mehen holds it: its status, limits, defaults and their revision, how many subjects it has, and its keys. |
| [`subjects.list`](#subjectslist) | `GET /v1/platform/subjects` | Lists the platform's own subjects, a page at a time, with each one's status. |
| [`defaults.put`](#defaultsput) | `PUT /v1/platform/defaults` | Replaces the platform's defaults: which templates and providers its users may add, default OAuth scopes and tags, rules added to a template's or provider's policy, and whether keys with no template may be read without a hold. |
| [`subjects.removeAll`](#subjectsremoveall) | `DELETE /v1/platform/subjects` | Deletes every subject the platform has, each as deleting one does, 50 at a time. |

One subject's routes, each under `/v1/platform/subjects/:subject`:

| Route | Method and path | What it does |
| --- | --- | --- |
| [`subjects.ensure`](#subjectsensure) | `PUT /v1/platform/subjects/:subject` | Makes sure the subject has its managed workspace: 201 when it is made, 200 when it already existed. |
| [`subjects.get`](#subjectsget) | `GET /v1/platform/subjects/:subject` | Reads one subject: its status and how many devices, items and projects its workspace holds. |
| [`subjects.remove`](#subjectsremove) | `DELETE /v1/platform/subjects/:subject` | Deletes a subject: every device is revoked, every secret and item deleted, and the audit kept until the purge date in the answer, when the whole workspace is erased. |
| [`devices.create`](#devicescreate) | `POST /v1/platform/subjects/:subject/devices` | Provisions a device in the subject's workspace for one of the platform's agents, with no enrolment page. |
| [`devices.list`](#deviceslist) | `GET /v1/platform/subjects/:subject/devices` | Lists the subject's devices, with when each was last seen and how many live workloads it has. |
| [`devices.revoke`](#devicesrevoke) | `DELETE /v1/platform/subjects/:subject/devices/:device` | Revokes a device. |
| [`items.list`](#itemslist) | `GET /v1/platform/subjects/:subject/items` | Lists the subject's items by Mehen's metadata (name, kind, provider, account, tags, hosts, status and policy). |
| [`items.remove`](#itemsremove) | `DELETE /v1/platform/subjects/:subject/items/:slug` | Deletes an item and its secrets. |
| [`intakes.create`](#intakescreate) | `POST /v1/platform/subjects/:subject/intakes` | Opens a request for a credential and answers the vault's seal key and the fields to fill in: a key from a catalogue template or for one host, or the person's own OAuth app, for a catalogue provider (with its set-up steps) or with its own provider definition. |
| [`intakes.submit`](#intakessubmit) | `POST /v1/platform/subjects/:subject/intakes/:intake/submit` | Submits the sealed values for an open request. |
| [`intakes.cancel`](#intakescancel) | `POST /v1/platform/subjects/:subject/intakes/:intake/cancel` | Cancels an open request for a credential. |
| [`connects.start`](#connectsstart) | `POST /v1/platform/subjects/:subject/connects` | Starts connecting an account with the person's own OAuth app, held in their workspace, and answers the provider's authorisation URL, carrying Mehen's state, the platform's callback and a PKCE challenge where the provider supports one, for the platform to send the person to. |
| [`connects.finish`](#connectsfinish) | `POST /v1/platform/subjects/:subject/connects/finish` | Finishes a connection with what the platform's callback received. |
| [`connects.get`](#connectsget) | `GET /v1/platform/subjects/:subject/connects/:connect` | Reads a connection's progress and outcome. |
| [`connects.cancel`](#connectscancel) | `POST /v1/platform/subjects/:subject/connects/:connect/cancel` | Cancels a connection that has not finished. |
| [`approvals.list`](#approvalslist) | `GET /v1/platform/subjects/:subject/approvals` | Lists the subject's held requests, filtered by status. |
| [`approvals.get`](#approvalsget) | `GET /v1/platform/subjects/:subject/approvals/:approval` | Reads one held request: the exact request it would send, its digest, the device and workload that asked, what Mehen could not read, and who decided it. |
| [`approvals.decide`](#approvalsdecide) | `POST /v1/platform/subjects/:subject/approvals/:approval/decide` | Approves or denies a held request, naming its digest. |
| [`projects.create`](#projectscreate) | `POST /v1/platform/subjects/:subject/projects` | Creates a project, a named set of settings and keys for computers, with its environments. |
| [`projects.get`](#projectsget) | `GET /v1/platform/subjects/:subject/projects/:project` | Reads a project and its variables, plain values included. |
| [`projects.setVar`](#projectssetvar) | `PUT /v1/platform/subjects/:subject/projects/:project/vars/:name` | Sets one variable in one environment to a plain value or an item's field, or unsets it when neither is sent. |
| [`projects.remove`](#projectsremove) | `DELETE /v1/platform/subjects/:subject/projects/:project` | Deletes a project. |
| [`audit.read`](#auditread) | `GET /v1/platform/subjects/:subject/audit` | Reads the subject's audit chain a page at a time, every row, with its head and whether it verified. |
| [`workspace.freeze`](#workspacefreeze) | `POST /v1/platform/subjects/:subject/freeze` | Freezes the subject's workspace: nothing is used or changed until it is unfrozen. |
| [`workspace.unfreeze`](#workspaceunfreeze) | `POST /v1/platform/subjects/:subject/unfreeze` | Unfreezes the subject's workspace. |

## Common refusals

Any route may answer these, beyond its own. Every refusal is an error object with the header `Mehen-Error: 1`.

| `error` | When |
| --- | --- |
| [`unauthenticated`](https://docs.mehen.dev/reference/errors.md#unauthenticated) | No proof, or its key is unknown, revoked, past its rotation date or not this platform's. |
| [`proof_invalid`](https://docs.mehen.dev/reference/errors.md#proof_invalid) | The proof's signature, time, method, URL, body hash or subject does not match the request, or its nonce was used before. |
| [`platform_suspended`](https://docs.mehen.dev/reference/errors.md#platform_suspended) | Mehen's operator suspended or retired the platform. |
| [`rate_limited`](https://docs.mehen.dev/reference/errors.md#rate_limited) | Too many calls or decisions for the platform or the subject; wait `retryAfter` seconds. |
| [`bad_request`](https://docs.mehen.dev/reference/errors.md#bad_request) | The path, query or body does not have the shape this page gives. |
| [`not_found`](https://docs.mehen.dev/reference/errors.md#not_found) | The subject, or the object the path names, is not this platform's, exactly as if it did not exist. |

## Limits

Mehen's operator sets each platform's limits when it registers the platform, and may change them later. Every managed workspace reads them live; past one, a route answers `quota_exceeded` or `rate_limited`. The defaults:

| Limit | Default | Range | What it limits |
| --- | --- | --- | --- |
| `subjects` | 1,000 | 1 to 10,000,000 | How many subjects (users) the platform may have at once, counting those still being deleted. |
| `devicesPerSubject` | 16 | 1 to 1,000 | Live devices in one subject's workspace. A revoked device no longer counts. |
| `itemsPerSubject` | 100 | 1 to 10,000 | Credentials in one subject's workspace: keys, OAuth apps and connections together. |
| `projectsPerSubject` | 16 | 1 to 1,000 | Projects in one subject's workspace. Each environment holds up to 500 variables, as everywhere in Mehen. |
| `openRequestsPerSubject` | 10 | 1 to 100 | Requests for a credential and connections the platform has started in one subject's workspace and not yet finished or cancelled. |
| `callsPerMinute` | 600 | 1 to 100,000 | Platform calls a minute, across all of the platform's subjects. A call is counted once its proof is verified; past it, `rate_limited`. |
| `subjectWritesPerMinute` | 60 | 1 to 10,000 | Calls that change something, a minute, in one subject's workspace. |
| `decisionsPerMinute` | 30 | 1 to 10,000 | Approvals and denials a minute, across all of the platform's subjects. |
| `subjectDecisionsPerMinute` | 10 | 1 to 1,000 | Approvals and denials a minute, in one subject's workspace. |
| `decisionTtlSeconds` | 900 | 60 to 900 | The longest an approval the platform gives may stay usable, in seconds. An approval always serves one use. |
| `auditRetentionDays` | 30 | 0 to 90 | How long a deleted subject's audit is kept before the whole workspace is erased, in days. Secrets are deleted at once, whatever this says. |

## The platform

### `platform.view`

```http
GET /v1/platform
```

Reads the platform as Mehen holds it: its status, limits, defaults and their revision, how many subjects it has, and its keys.

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `platform` | object | yes |
| `platform.id` | string matching `^plt_[A-Za-z0-9_-]{4,64}$` | yes |
| `platform.slug` | string matching `^[a-z][a-z0-9-]{2,39}$` | yes |
| `platform.name` | string | yes |
| `platform.status` | one of `active`, `suspended`, `retiring`, `retired` | yes |
| `limits` | object | yes |
| `limits.subjects` | integer, 1 to 10,000,000 | yes |
| `limits.devicesPerSubject` | integer, 1 to 1,000 | yes |
| `limits.itemsPerSubject` | integer, 1 to 10,000 | yes |
| `limits.projectsPerSubject` | integer, 1 to 1,000 | yes |
| `limits.openRequestsPerSubject` | integer, 1 to 100 | yes |
| `limits.callsPerMinute` | integer, 1 to 100,000 | yes |
| `limits.subjectWritesPerMinute` | integer, 1 to 10,000 | yes |
| `limits.decisionsPerMinute` | integer, 1 to 10,000 | yes |
| `limits.subjectDecisionsPerMinute` | integer, 1 to 1,000 | yes |
| `limits.decisionTtlSeconds` | integer, 60 to 900 | yes |
| `limits.auditRetentionDays` | integer, 0 to 90 | yes |
| `defaults` | object | yes |
| `defaults.templates` | array of string, 1 to 64 characters, at most 256 items | no |
| `defaults.providers` | array of string, 1 to 64 characters, at most 64 items | no |
| `defaults.scopes` | map of keys (string, 1 to 64 characters) to values (array of string, 1 to 200 characters, at most 50 items) | yes |
| `defaults.tags` | array of string matching `^[a-z0-9][a-z0-9:_-]{0,39}$`, at most 16 items | yes |
| `defaults.customReads` | one of `allow`, `approve` | yes |
| `defaults.rules` | map of keys (string, 1 to 64 characters) to values (array of object, at most 64 items) | yes |
| `defaults.rules.<name>[].action` | one of `allow`, `approve`, `deny` | yes |
| `defaults.rules.<name>[].methods` | array of string, 1 to 16 characters, at most 16 items | no |
| `defaults.rules.<name>[].paths` | array of string, at most 256 characters, at most 32 items | no |
| `defaults.rules.<name>[].body` | one of `graphql-mutation`, `graphql-query` | no |
| `defaults.rules.<name>[].note` | string, at most 200 characters | no |
| `defaultsRevision` | integer, at least 1 | yes |
| `usage` | object | yes |
| `usage.subjects` | integer | yes |
| `keys` | array of object | yes |
| `keys[].id` | string matching `^pky_[A-Za-z0-9_-]{4,64}$` | yes |
| `keys[].createdAt` | time (ISO 8601) | yes |
| `keys[].expiresAt` | time (ISO 8601), or null | yes |
| `keys[].revoked` | boolean | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `subjects.list`

```http
GET /v1/platform/subjects
```

Lists the platform's own subjects, a page at a time, with each one's status. Another platform's subjects are never listed.

**Query.** Every value is a string on the wire.

| Parameter | Type | Required |
| --- | --- | --- |
| `cursor` | string, 1 to 256 characters | no |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `subjects` | array of object, at most 100 items | yes |
| `subjects[].subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `subjects[].status` | one of `creating`, `active`, `deleting`, `deleted` | yes |
| `subjects[].createdAt` | time (ISO 8601) | yes |
| `next` | string, or null | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `defaults.put`

```http
PUT /v1/platform/defaults
```

Replaces the platform's defaults: which templates and providers its users may add, default OAuth scopes and tags, rules added to a template's or provider's policy, and whether keys with no template may be read without a hold. Rules may only hold or refuse, never allow. Every workspace uses the new defaults within 30 seconds, and approvals made under the old ones go stale.

**Body.**

| Field | Type | Required |
| --- | --- | --- |
| `templates` | array of string, 1 to 64 characters, at most 256 items | no |
| `providers` | array of string, 1 to 64 characters, at most 64 items | no |
| `scopes` | map of keys (string, 1 to 64 characters) to values (array of string, 1 to 200 characters, at most 50 items) | yes |
| `tags` | array of string matching `^[a-z0-9][a-z0-9:_-]{0,39}$`, at most 16 items | yes |
| `customReads` | one of `allow`, `approve` | yes |
| `rules` | map of keys (string, 1 to 64 characters) to values (array of object, at most 64 items) | yes |
| `rules.<name>[].action` | one of `allow`, `approve`, `deny` | yes |
| `rules.<name>[].methods` | array of string, 1 to 16 characters, at most 16 items | no |
| `rules.<name>[].paths` | array of string, at most 256 characters, at most 32 items | no |
| `rules.<name>[].body` | one of `graphql-mutation`, `graphql-query` | no |
| `rules.<name>[].note` | string, at most 200 characters | no |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `defaults` | object | yes |
| `defaults.templates` | array of string, 1 to 64 characters, at most 256 items | no |
| `defaults.providers` | array of string, 1 to 64 characters, at most 64 items | no |
| `defaults.scopes` | map of keys (string, 1 to 64 characters) to values (array of string, 1 to 200 characters, at most 50 items) | yes |
| `defaults.tags` | array of string matching `^[a-z0-9][a-z0-9:_-]{0,39}$`, at most 16 items | yes |
| `defaults.customReads` | one of `allow`, `approve` | yes |
| `defaults.rules` | map of keys (string, 1 to 64 characters) to values (array of object, at most 64 items) | yes |
| `defaults.rules.<name>[].action` | one of `allow`, `approve`, `deny` | yes |
| `defaults.rules.<name>[].methods` | array of string, 1 to 16 characters, at most 16 items | no |
| `defaults.rules.<name>[].paths` | array of string, at most 256 characters, at most 32 items | no |
| `defaults.rules.<name>[].body` | one of `graphql-mutation`, `graphql-query` | no |
| `defaults.rules.<name>[].note` | string, at most 200 characters | no |
| `defaultsRevision` | integer, at least 1 | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

The table gives a rule's `action` as the policy shape allows it; a platform's rule may only be `approve` or `deny`, and an `allow` rule is refused with `bad_request`. So are a template, provider or scope the catalogue lacks, and a tag given twice.

### `subjects.removeAll`

```http
DELETE /v1/platform/subjects
```

Deletes every subject the platform has, each as deleting one does, 50 at a time. `confirm` must be the platform's own slug. The platform stays registered.

**Body.**

| Field | Type | Required |
| --- | --- | --- |
| `confirm` | string matching `^[a-z][a-z0-9-]{2,39}$` | yes |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `deleting` | integer | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

## A subject

Every route here names one subject in its path, and its proof's `sub` claim must be that same subject. A subject the platform does not have is `not_found`, exactly as one that never existed.

### `subjects.ensure`

```http
PUT /v1/platform/subjects/:subject
```

Makes sure the subject has its managed workspace: 201 when it is made, 200 when it already existed. Refused while the subject is still being deleted, and while the platform is retiring.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Body.** `{}`

**Answer (200 or 201).**

| Field | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `status` | one of `creating`, `active`, `deleting`, `deleted` | yes |
| `createdAt` | time (ISO 8601) | yes |
| `counts` | object | yes |
| `counts.devices` | integer | yes |
| `counts.items` | integer | yes |
| `counts.projects` | integer | yes |

**Its own refusals**, beyond the [common ones](#common-refusals): [`quota_exceeded`](https://docs.mehen.dev/reference/errors.md#quota_exceeded), [`conflict`](https://docs.mehen.dev/reference/errors.md#conflict).

### `subjects.get`

```http
GET /v1/platform/subjects/:subject
```

Reads one subject: its status and how many devices, items and projects its workspace holds. A deleted subject is answered until its purge, so its audit can still be read.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `status` | one of `creating`, `active`, `deleting`, `deleted` | yes |
| `createdAt` | time (ISO 8601) | yes |
| `counts` | object | yes |
| `counts.devices` | integer | yes |
| `counts.items` | integer | yes |
| `counts.projects` | integer | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `subjects.remove`

```http
DELETE /v1/platform/subjects/:subject
```

Deletes a subject: every device is revoked, every secret and item deleted, and the audit kept until the purge date in the answer, when the whole workspace is erased. Repeating it answers the same.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Body.** `{}`

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `status` | `deleted` | yes |
| `purgeAfter` | time (ISO 8601) | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `devices.create`

```http
POST /v1/platform/subjects/:subject/devices
```

Provisions a device in the subject's workspace for one of the platform's agents, with no enrolment page. It may only relay and serve placeholders, and acts only through workloads. The same key again answers the existing device with 200.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Body.**

| Field | Type | Required |
| --- | --- | --- |
| `name` | string, 1 to 64 characters | yes |
| `jwk` | object | yes |
| `jwk.kty` | `EC` | yes |
| `jwk.crv` | `P-256` | yes |
| `jwk.x` | string matching `^[A-Za-z0-9_-]{43}$` | yes |
| `jwk.y` | string matching `^[A-Za-z0-9_-]{43}$` | yes |
| `modes` | array of one of `relay`, `placeholder`, 1 to 2 items | yes |
| `label` | string matching `^[A-Za-z0-9_.:-]{1,120}$` | no |

**Answer (200 or 201).**

| Field | Type | Required |
| --- | --- | --- |
| `device` | string matching `^dev_[A-Za-z0-9_-]{1,60}$` | yes |
| `tenant` | string matching `^tn_[A-Za-z0-9]{8,40}$` | yes |
| `name` | string | yes |
| `label` | string, or null | yes |
| `scope` | object | yes |
| `scope.tags` | array of string matching `^[a-z0-9][a-z0-9:_-]{0,39}$`, at most 32 items | yes |
| `scope.projects` | array of string, 1 to 64 characters, at most 32 items | yes |
| `scope.maxEnvironment` | one of `unprotected`, `protected` | yes |
| `scope.modes` | array of one of `relay`, `sign`, `mint`, `placeholder`, `lease`, 1 to 5 items | yes |
| `scope.privateCredentials` | boolean | yes |
| `scope.workloads` | one of `allowed`, `required` | no |
| `createdAt` | time (ISO 8601) | yes |
| `revoked` | boolean | yes |

**Its own refusals**, beyond the [common ones](#common-refusals): [`quota_exceeded`](https://docs.mehen.dev/reference/errors.md#quota_exceeded), [`conflict`](https://docs.mehen.dev/reference/errors.md#conflict), [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

### `devices.list`

```http
GET /v1/platform/subjects/:subject/devices
```

Lists the subject's devices, with when each was last seen and how many live workloads it has.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `devices` | array of object | yes |
| `devices[].device` | string matching `^dev_[A-Za-z0-9_-]{1,60}$` | yes |
| `devices[].tenant` | string matching `^tn_[A-Za-z0-9]{8,40}$` | yes |
| `devices[].name` | string | yes |
| `devices[].label` | string, or null | yes |
| `devices[].scope` | object | yes |
| `devices[].scope.tags` | array of string matching `^[a-z0-9][a-z0-9:_-]{0,39}$`, at most 32 items | yes |
| `devices[].scope.projects` | array of string, 1 to 64 characters, at most 32 items | yes |
| `devices[].scope.maxEnvironment` | one of `unprotected`, `protected` | yes |
| `devices[].scope.modes` | array of one of `relay`, `sign`, `mint`, `placeholder`, `lease`, 1 to 5 items | yes |
| `devices[].scope.privateCredentials` | boolean | yes |
| `devices[].scope.workloads` | one of `allowed`, `required` | no |
| `devices[].createdAt` | time (ISO 8601) | yes |
| `devices[].revoked` | boolean | yes |
| `devices[].lastSeenAt` | time (ISO 8601), or null | yes |
| `devices[].liveWorkloads` | integer | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `devices.revoke`

```http
DELETE /v1/platform/subjects/:subject/devices/:device
```

Revokes a device. Its workloads, open asks and runs end with it. Repeating it answers the same.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `device` | string matching `^dev_[A-Za-z0-9_-]{1,60}$` | yes |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `device` | string matching `^dev_[A-Za-z0-9_-]{1,60}$` | yes |
| `tenant` | string matching `^tn_[A-Za-z0-9]{8,40}$` | yes |
| `name` | string | yes |
| `label` | string, or null | yes |
| `scope` | object | yes |
| `scope.tags` | array of string matching `^[a-z0-9][a-z0-9:_-]{0,39}$`, at most 32 items | yes |
| `scope.projects` | array of string, 1 to 64 characters, at most 32 items | yes |
| `scope.maxEnvironment` | one of `unprotected`, `protected` | yes |
| `scope.modes` | array of one of `relay`, `sign`, `mint`, `placeholder`, `lease`, 1 to 5 items | yes |
| `scope.privateCredentials` | boolean | yes |
| `scope.workloads` | one of `allowed`, `required` | no |
| `createdAt` | time (ISO 8601) | yes |
| `revoked` | boolean | yes |

**Its own refusals**, beyond the [common ones](#common-refusals): [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

### `items.list`

```http
GET /v1/platform/subjects/:subject/items
```

Lists the subject's items by Mehen's metadata (name, kind, provider, account, tags, hosts, status and policy). Never a value.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Query.** Every value is a string on the wire.

| Parameter | Type | Required |
| --- | --- | --- |
| `kind` | one of `api-key`, `bearer-token`, `basic-auth`, `custom-headers`, `oauth-client`, `oauth-connection`, `google-service-account`, `github-app`, `hmac-key`, `ssh-key`, `password-login`, `totp`, `database-url`, `smtp-imap`, `cloud-access-key`, `file`, `note`, `generic` | no |
| `tag` | string matching `^[a-z0-9][a-z0-9:_-]{0,39}$` | no |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `items` | array of object | yes |
| `items[].slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `items[].kind` | one of `api-key`, `bearer-token`, `basic-auth`, `custom-headers`, `oauth-client`, `oauth-connection`, `google-service-account`, `github-app`, `hmac-key`, `ssh-key`, `password-login`, `totp`, `database-url`, `smtp-imap`, `cloud-access-key`, `file`, `note`, `generic` | yes |
| `items[].provider` | string, or null | yes |
| `items[].account` | string, or null | yes |
| `items[].accountVerified` | boolean | yes |
| `items[].tags` | array of string | yes |
| `items[].ceiling` | one of `relay`, `sign`, `mint`, `placeholder`, `lease`, `reveal` | yes |
| `items[].hosts` | array of string | yes |
| `items[].status` | one of `active`, `needs_reauth`, `refresh_retrying`, `disabled` | yes |
| `items[].review` | object | no |
| `items[].review.reason` | string | yes |
| `items[].review.since` | string | yes |
| `items[].updatedAt` | string | yes |
| `items[].lastUsedAt` | string, or null | no |
| `items[].policy` | object | no |
| `items[].policy.default` | one of `allow`, `approve`, `deny` | yes |
| `items[].policy.rules` | array of object, at most 64 items | yes |
| `items[].policy.rules[].action` | one of `allow`, `approve`, `deny` | yes |
| `items[].policy.rules[].methods` | array of string, 1 to 16 characters, at most 16 items | no |
| `items[].policy.rules[].paths` | array of string, at most 256 characters, at most 32 items | no |
| `items[].policy.rules[].body` | one of `graphql-mutation`, `graphql-query` | no |
| `items[].policy.rules[].note` | string, at most 200 characters | no |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `items.remove`

```http
DELETE /v1/platform/subjects/:subject/items/:slug
```

Deletes an item and its secrets. Approvals and grants on it end. An OAuth connection's token is handed back to the provider first where the provider allows it.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `deleted` | `true` | yes |
| `providerRevoked` | boolean | yes |

**Its own refusals**, beyond the [common ones](#common-refusals): [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

### `intakes.create`

```http
POST /v1/platform/subjects/:subject/intakes
```

Opens a request for a credential and answers the vault's seal key and the fields to fill in: a key from a catalogue template or for one host, or the person's own OAuth app, for a catalogue provider (with its set-up steps) or with its own provider definition. The person types the values into the platform's own page, which seals each secret in their browser before it reaches the platform. An existing name of the same kind is a rotation and keeps where the key may go. It lives 10 minutes and carries no link.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Body.**

| Field | Type | Required |
| --- | --- | --- |
| `slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `service` | object, one of 3 shapes | yes |
| `service.template` | string, 1 to 64 characters | in its shape |
| `service.host` | string | in its shape |
| `service.customProvider` | object | in its shape |
| `service.customProvider.name` | string, 1 to 64 characters | yes |
| `service.customProvider.authorizeUrl` | URL, at most 2048 characters | yes |
| `service.customProvider.tokenUrl` | URL, at most 2048 characters | yes |
| `service.customProvider.scopes` | array of string matching `^[\x21\x23-\x2B\x2D-\x5B\x5D-\x7E]{1,200}$`, at most 50 items | yes |
| `service.customProvider.scopeSeparator` | one of `" "`, `,` | no |
| `service.customProvider.pkce` | one of `S256`, `none` | yes |
| `service.customProvider.clientAuth` | one of `client_secret_post`, `client_secret_basic`, `none` | yes |
| `service.customProvider.authorizeParams` | map of keys (string matching `^[A-Za-z0-9_.-]{1,64}$`) to values (string, at most 200 characters) | no |
| `service.customProvider.hosts` | array of string, 1 to 16 items | yes |
| `service.customProvider.injection` | object, one of 2 shapes | no |
| `service.customProvider.injection.in` | `header` or `query` | yes |
| `service.customProvider.injection.name` | string matching `^[A-Za-z0-9-]{1,64}$` or string matching `^[A-Za-z0-9_.-]{1,64}$` | yes |
| `service.customProvider.injection.format` | string, 1 to 200 characters | in its shape |
| `service.customProvider.injection.field` | `access_token` | in its shape |
| `tags` | array of string matching `^[a-z0-9][a-z0-9:_-]{0,39}$`, at most 16 items | no |
| `reason` | string, 1 to 500 characters | no |

**Answer (201).**

| Field | Type | Required |
| --- | --- | --- |
| `intake` | string matching `^int_[A-Za-z0-9_-]{1,60}$` | yes |
| `sealKey` | object | yes |
| `sealKey.jwk` | object | yes |
| `sealKey.jwk.kty` | `EC` | yes |
| `sealKey.jwk.crv` | `P-256` | yes |
| `sealKey.jwk.x` | string | yes |
| `sealKey.jwk.y` | string | yes |
| `sealKey.kid` | string | yes |
| `fields` | array of object | yes |
| `fields[].name` | string matching `^[a-z][a-z0-9_]{0,63}$` | yes |
| `fields[].label` | string | yes |
| `fields[].secret` | boolean | yes |
| `fields[].required` | boolean | yes |
| `fields[].multiline` | boolean | yes |
| `fields[].hint` | string, or null | yes |
| `binding` | object | yes |
| `binding.hosts` | array of string | yes |
| `binding.known` | boolean | yes |
| `rotates` | boolean | yes |
| `setup` | array of object, or null | yes |
| `setup[].title` | string, 1 to 80 characters | yes |
| `setup[].body` | string, 1 to 400 characters | yes |
| `setup[].consoleUrl` | URL | no |
| `expiresAt` | time (ISO 8601) | yes |

**Its own refusals**, beyond the [common ones](#common-refusals): [`conflict`](https://docs.mehen.dev/reference/errors.md#conflict), [`quota_exceeded`](https://docs.mehen.dev/reference/errors.md#quota_exceeded), [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

`service` is one of three shapes. `{ template }` names a catalogue template: a key's, or a provider's app template such as `google-oauth-client`, whose answer carries the template's set-up steps in `setup`, each body possibly holding `{callback}` for the platform's page to replace with its own callback URL. `{ host }` is a key for one API host with no template; every request with it is held unless the platform's `customReads` lets reads through. `{ customProvider }` is the user's own OAuth app with its own provider definition: `authorizeUrl` and `tokenUrl` are https on a public host name, with no user name, password or fragment, and never an IP address or a private name; `authorizeParams` holds at most 8 names, none that Mehen sets itself; each scope and host appears once. The shape allows `clientAuth: "none"`, a public app with no secret, but the vault does not support one yet and refuses its intake with `bad_request`. The definition is fixed with the app, and the app's client secret is bound to the token URL's host and path, by `POST`, so it is sent nowhere else. A template outside the platform's `templates` default is `bad_request`. A `slug` the user already has is a rotation of that credential: of another kind or service it is `conflict`, and so is a different definition under an existing app's name.

### `intakes.submit`

```http
POST /v1/platform/subjects/:subject/intakes/:intake/submit
```

Submits the sealed values for an open request. Each box opens only for this request and field, and only once. The name, where the key may go, its tags and its home are the request's, never the body's.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `intake` | string matching `^int_[A-Za-z0-9_-]{1,60}$` | yes |

**Body.**

| Field | Type | Required |
| --- | --- | --- |
| `sealed` | map of keys (string matching `^[a-z][a-z0-9_]{0,63}$`) to values (object) | yes |
| `sealed.<name>.v` | `1` | yes |
| `sealed.<name>.epk` | object | yes |
| `sealed.<name>.epk.kty` | `EC` | yes |
| `sealed.<name>.epk.crv` | `P-256` | yes |
| `sealed.<name>.epk.x` | string matching `^[A-Za-z0-9_-]{43}$` | yes |
| `sealed.<name>.epk.y` | string matching `^[A-Za-z0-9_-]{43}$` | yes |
| `sealed.<name>.iv` | string matching `^[A-Za-z0-9_-]{16}$` | yes |
| `sealed.<name>.ct` | string matching `^[A-Za-z0-9_-]+$` | yes |
| `publicFields` | map of keys (string matching `^[a-z][a-z0-9_]{0,63}$`) to values (string, at most 4,096 characters) | no |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `item` | object | yes |
| `item.slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `item.kind` | one of `api-key`, `bearer-token`, `basic-auth`, `custom-headers`, `oauth-client`, `oauth-connection`, `google-service-account`, `github-app`, `hmac-key`, `ssh-key`, `password-login`, `totp`, `database-url`, `smtp-imap`, `cloud-access-key`, `file`, `note`, `generic` | yes |
| `item.provider` | string, or null | yes |
| `item.account` | string, or null | yes |
| `item.accountVerified` | boolean | yes |
| `item.tags` | array of string | yes |
| `item.ceiling` | one of `relay`, `sign`, `mint`, `placeholder`, `lease`, `reveal` | yes |
| `item.hosts` | array of string | yes |
| `item.status` | one of `active`, `needs_reauth`, `refresh_retrying`, `disabled` | yes |
| `item.review` | object | no |
| `item.review.reason` | string | yes |
| `item.review.since` | string | yes |
| `item.updatedAt` | string | yes |
| `item.lastUsedAt` | string, or null | no |
| `item.policy` | object | no |
| `item.policy.default` | one of `allow`, `approve`, `deny` | yes |
| `item.policy.rules` | array of object, at most 64 items | yes |
| `item.policy.rules[].action` | one of `allow`, `approve`, `deny` | yes |
| `item.policy.rules[].methods` | array of string, 1 to 16 characters, at most 16 items | no |
| `item.policy.rules[].paths` | array of string, at most 256 characters, at most 32 items | no |
| `item.policy.rules[].body` | one of `graphql-mutation`, `graphql-query` | no |
| `item.policy.rules[].note` | string, at most 200 characters | no |

**Its own refusals**, beyond the [common ones](#common-refusals): [`conflict`](https://docs.mehen.dev/reference/errors.md#conflict), [`quota_exceeded`](https://docs.mehen.dev/reference/errors.md#quota_exceeded), [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

Seal each field the intake marked `secret` under `intake/<intake>/<field>`, and send the others, such as an OAuth app's `client_id`, in `publicFields`.

### `intakes.cancel`

```http
POST /v1/platform/subjects/:subject/intakes/:intake/cancel
```

Cancels an open request for a credential.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `intake` | string matching `^int_[A-Za-z0-9_-]{1,60}$` | yes |

**Body.** `{}`

**Answer (200).** `{}`

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `connects.start`

```http
POST /v1/platform/subjects/:subject/connects
```

Starts connecting an account with the person's own OAuth app, held in their workspace, and answers the provider's authorisation URL, carrying Mehen's state, the platform's callback and a PKCE challenge where the provider supports one, for the platform to send the person to. Without scopes it asks for the platform's default scopes for a catalogue provider, else the catalogue's, or every scope of the app's own provider definition. It lives 15 minutes.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Body.**

| Field | Type | Required |
| --- | --- | --- |
| `provider` | string, 1 to 64 characters | no |
| `client` | string matching `^[a-z][a-z0-9-]{1,63}$` | no |
| `slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `redirectUri` | URL, at most 2048 characters | yes |
| `scopes` | array of string, 1 to 200 characters, 1 to 50 items | no |
| `expect` | string, 1 to 320 characters | no |
| `tags` | array of string matching `^[a-z0-9][a-z0-9:_-]{0,39}$`, at most 16 items | no |

**Answer (201).**

| Field | Type | Required |
| --- | --- | --- |
| `connect` | string matching `^con_[A-Za-z0-9_-]{1,60}$` | yes |
| `authorizeUrl` | URL | yes |
| `expiresAt` | time (ISO 8601) | yes |

**Its own refusals**, beyond the [common ones](#common-refusals): [`conflict`](https://docs.mehen.dev/reference/errors.md#conflict), [`quota_exceeded`](https://docs.mehen.dev/reference/errors.md#quota_exceeded), [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

Name a catalogue `provider`, and optionally the `client`, the user's app for it (by default the app named after the provider's app template, such as `google-oauth-client`); or name only the `client`, an app with its own provider definition. `redirectUri` is the platform's own callback, exactly as the user registered it in their app. A `provider` outside the platform's `providers` default is `bad_request`. An existing connection's `slug` is a reconnect: for a catalogue provider, pinned to the same account at the provider; for an app with its own definition, through the same app. A device's own `POST /v1/connect` is refused with `forbidden` in a managed workspace: connections start here.

### `connects.finish`

```http
POST /v1/platform/subjects/:subject/connects/finish
```

Finishes a connection with what the platform's callback received. Mehen exchanges the code with the app's client secret, which only Mehen holds, and its own PKCE verifier where it sent a challenge, so the code is useless to anyone else. A state that is not this subject's is not found; a state already used or expired is a conflict.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Body.**

| Field | Type | Required |
| --- | --- | --- |
| `state` | string, 16 to 256 characters | yes |
| `code` | string, 1 to 4,096 characters | no |
| `error` | string, 1 to 256 characters | no |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `connect` | string matching `^con_[A-Za-z0-9_-]{1,60}$` | yes |
| `status` | `completed` or `failed` | yes |
| `item` | object | in its shape |
| `item.slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `item.kind` | one of `api-key`, `bearer-token`, `basic-auth`, `custom-headers`, `oauth-client`, `oauth-connection`, `google-service-account`, `github-app`, `hmac-key`, `ssh-key`, `password-login`, `totp`, `database-url`, `smtp-imap`, `cloud-access-key`, `file`, `note`, `generic` | yes |
| `item.provider` | string, or null | yes |
| `item.account` | string, or null | yes |
| `item.accountVerified` | boolean | yes |
| `item.tags` | array of string | yes |
| `item.ceiling` | one of `relay`, `sign`, `mint`, `placeholder`, `lease`, `reveal` | yes |
| `item.hosts` | array of string | yes |
| `item.status` | one of `active`, `needs_reauth`, `refresh_retrying`, `disabled` | yes |
| `item.review` | object | no |
| `item.review.reason` | string | yes |
| `item.review.since` | string | yes |
| `item.updatedAt` | string | yes |
| `item.lastUsedAt` | string, or null | no |
| `item.policy` | object | no |
| `item.policy.default` | one of `allow`, `approve`, `deny` | yes |
| `item.policy.rules` | array of object, at most 64 items | yes |
| `item.policy.rules[].action` | one of `allow`, `approve`, `deny` | yes |
| `item.policy.rules[].methods` | array of string, 1 to 16 characters, at most 16 items | no |
| `item.policy.rules[].paths` | array of string, at most 256 characters, at most 32 items | no |
| `item.policy.rules[].body` | one of `graphql-mutation`, `graphql-query` | no |
| `item.policy.rules[].note` | string, at most 200 characters | no |
| `reason` | one of `denied`, `provider`, `mismatch`, `wider` | in its shape |

**Its own refusals**, beyond the [common ones](#common-refusals): [`conflict`](https://docs.mehen.dev/reference/errors.md#conflict), [`quota_exceeded`](https://docs.mehen.dev/reference/errors.md#quota_exceeded), [`upstream_failed`](https://docs.mehen.dev/reference/errors.md#upstream_failed), [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

Send the `state` and either the `code` or the provider's `error` from the callback, never both. Name the subject who is signed in to the platform at the callback, not the one the platform expects. A `completed` connection made with a catalogue provider names the account the provider verified; one made with an app's own definition names none.

### `connects.get`

```http
GET /v1/platform/subjects/:subject/connects/:connect
```

Reads a connection's progress and outcome.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `connect` | string matching `^con_[A-Za-z0-9_-]{1,60}$` | yes |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `connect` | string matching `^con_[A-Za-z0-9_-]{1,60}$` | yes |
| `status` | one of `open`, `completed`, `mismatch`, `failed`, `expired` | yes |
| `item` | object | no |
| `item.slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `item.kind` | one of `api-key`, `bearer-token`, `basic-auth`, `custom-headers`, `oauth-client`, `oauth-connection`, `google-service-account`, `github-app`, `hmac-key`, `ssh-key`, `password-login`, `totp`, `database-url`, `smtp-imap`, `cloud-access-key`, `file`, `note`, `generic` | yes |
| `item.provider` | string, or null | yes |
| `item.account` | string, or null | yes |
| `item.accountVerified` | boolean | yes |
| `item.tags` | array of string | yes |
| `item.ceiling` | one of `relay`, `sign`, `mint`, `placeholder`, `lease`, `reveal` | yes |
| `item.hosts` | array of string | yes |
| `item.status` | one of `active`, `needs_reauth`, `refresh_retrying`, `disabled` | yes |
| `item.review` | object | no |
| `item.review.reason` | string | yes |
| `item.review.since` | string | yes |
| `item.updatedAt` | string | yes |
| `item.lastUsedAt` | string, or null | no |
| `item.policy` | object | no |
| `item.policy.default` | one of `allow`, `approve`, `deny` | yes |
| `item.policy.rules` | array of object, at most 64 items | yes |
| `item.policy.rules[].action` | one of `allow`, `approve`, `deny` | yes |
| `item.policy.rules[].methods` | array of string, 1 to 16 characters, at most 16 items | no |
| `item.policy.rules[].paths` | array of string, at most 256 characters, at most 32 items | no |
| `item.policy.rules[].body` | one of `graphql-mutation`, `graphql-query` | no |
| `item.policy.rules[].note` | string, at most 200 characters | no |
| `account` | string | no |
| `grantedScopes` | array of string | no |
| `message` | string | no |
| `expiresAt` | time (ISO 8601) | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `connects.cancel`

```http
POST /v1/platform/subjects/:subject/connects/:connect/cancel
```

Cancels a connection that has not finished.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `connect` | string matching `^con_[A-Za-z0-9_-]{1,60}$` | yes |

**Body.** `{}`

**Answer (200).** `{}`

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `approvals.list`

```http
GET /v1/platform/subjects/:subject/approvals
```

Lists the subject's held requests, filtered by status.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Query.** Every value is a string on the wire.

| Parameter | Type | Required |
| --- | --- | --- |
| `status` | one of `pending`, `approved`, `denied`, `expired`, `used`, `ended`, `revoked`, `stale` | no |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `approvals` | array of object | yes |
| `approvals[].approval` | string matching `^apr_[A-Za-z0-9_-]{1,60}$` | yes |
| `approvals[].status` | one of `pending`, `approved`, `denied`, `expired`, `used`, `ended`, `revoked`, `stale` | yes |
| `approvals[].digest` | string | yes |
| `approvals[].device` | string matching `^dev_[A-Za-z0-9_-]{1,60}$` | yes |
| `approvals[].workload` | object, or null | yes |
| `approvals[].workload.id` | string matching `^wl_[A-Za-z0-9_-]{8,64}$` | yes |
| `approvals[].workload.label` | string | yes |
| `approvals[].item` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `approvals[].request` | object | yes |
| `approvals[].request.method` | string | yes |
| `approvals[].request.url` | string | yes |
| `approvals[].request.contentType` | string, or null | yes |
| `approvals[].request.contentEncoding` | string, or null | yes |
| `approvals[].request.headersSha256` | string | yes |
| `approvals[].request.bodySha256` | string | yes |
| `approvals[].title` | string | yes |
| `approvals[].warnings` | array of string | yes |
| `approvals[].constraints` | object | yes |
| `approvals[].constraints.uses` | integer, at least 1, or null | yes |
| `approvals[].constraints.ttlSeconds` | integer, at least 1 | yes |
| `approvals[].expiresAt` | time (ISO 8601) | yes |
| `approvals[].decidedBy` | object, or null | yes |
| `approvals[].decidedBy.platform` | string matching `^plt_[A-Za-z0-9_-]{4,64}$` | yes |
| `approvals[].decidedBy.key` | string matching `^pky_[A-Za-z0-9_-]{4,64}$` | yes |
| `approvals[].decidedBy.ref` | string matching `^[A-Za-z0-9_.:-]{1,64}$`, or null | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `approvals.get`

```http
GET /v1/platform/subjects/:subject/approvals/:approval
```

Reads one held request: the exact request it would send, its digest, the device and workload that asked, what Mehen could not read, and who decided it.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `approval` | string matching `^apr_[A-Za-z0-9_-]{1,60}$` | yes |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `approval` | string matching `^apr_[A-Za-z0-9_-]{1,60}$` | yes |
| `status` | one of `pending`, `approved`, `denied`, `expired`, `used`, `ended`, `revoked`, `stale` | yes |
| `digest` | string | yes |
| `device` | string matching `^dev_[A-Za-z0-9_-]{1,60}$` | yes |
| `workload` | object, or null | yes |
| `workload.id` | string matching `^wl_[A-Za-z0-9_-]{8,64}$` | yes |
| `workload.label` | string | yes |
| `item` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `request` | object | yes |
| `request.method` | string | yes |
| `request.url` | string | yes |
| `request.contentType` | string, or null | yes |
| `request.contentEncoding` | string, or null | yes |
| `request.headersSha256` | string | yes |
| `request.bodySha256` | string | yes |
| `title` | string | yes |
| `warnings` | array of string | yes |
| `constraints` | object | yes |
| `constraints.uses` | integer, at least 1, or null | yes |
| `constraints.ttlSeconds` | integer, at least 1 | yes |
| `expiresAt` | time (ISO 8601) | yes |
| `decidedBy` | object, or null | yes |
| `decidedBy.platform` | string matching `^plt_[A-Za-z0-9_-]{4,64}$` | yes |
| `decidedBy.key` | string matching `^pky_[A-Za-z0-9_-]{4,64}$` | yes |
| `decidedBy.ref` | string matching `^[A-Za-z0-9_.:-]{1,64}$`, or null | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `approvals.decide`

```http
POST /v1/platform/subjects/:subject/approvals/:approval/decide
```

Approves or denies a held request, naming its digest. An approval is good for one use of that exact request by the workload that asked, for at most the platform's decision limit (15 minutes at most). A digest that is not the request's, or a request no longer pending, is a conflict: the request changed, ask again.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `approval` | string matching `^apr_[A-Za-z0-9_-]{1,60}$` | yes |

**Body.**

| Field | Type | Required |
| --- | --- | --- |
| `decision` | one of `approve`, `deny` | yes |
| `digest` | string matching `^[A-Za-z0-9_-]{43}$` | yes |
| `ttlSeconds` | integer, 60 to 900 | no |
| `ref` | string matching `^[A-Za-z0-9_.:-]{1,64}$` | no |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `approval` | string matching `^apr_[A-Za-z0-9_-]{1,60}$` | yes |
| `status` | one of `pending`, `approved`, `denied`, `expired`, `used`, `ended`, `revoked`, `stale` | yes |
| `digest` | string | yes |
| `device` | string matching `^dev_[A-Za-z0-9_-]{1,60}$` | yes |
| `workload` | object, or null | yes |
| `workload.id` | string matching `^wl_[A-Za-z0-9_-]{8,64}$` | yes |
| `workload.label` | string | yes |
| `item` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `request` | object | yes |
| `request.method` | string | yes |
| `request.url` | string | yes |
| `request.contentType` | string, or null | yes |
| `request.contentEncoding` | string, or null | yes |
| `request.headersSha256` | string | yes |
| `request.bodySha256` | string | yes |
| `title` | string | yes |
| `warnings` | array of string | yes |
| `constraints` | object | yes |
| `constraints.uses` | integer, at least 1, or null | yes |
| `constraints.ttlSeconds` | integer, at least 1 | yes |
| `expiresAt` | time (ISO 8601) | yes |
| `decidedBy` | object, or null | yes |
| `decidedBy.platform` | string matching `^plt_[A-Za-z0-9_-]{4,64}$` | yes |
| `decidedBy.key` | string matching `^pky_[A-Za-z0-9_-]{4,64}$` | yes |
| `decidedBy.ref` | string matching `^[A-Za-z0-9_.:-]{1,64}$`, or null | yes |

**Its own refusals**, beyond the [common ones](#common-refusals): [`conflict`](https://docs.mehen.dev/reference/errors.md#conflict), [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

### `projects.create`

```http
POST /v1/platform/subjects/:subject/projects
```

Creates a project, a named set of settings and keys for computers, with its environments. Every environment is unprotected. The same name again, with the same environments and default environment, answers the existing project with 200; the same name with other environments or another default environment is a conflict.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Body.**

| Field | Type | Required |
| --- | --- | --- |
| `slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `environments` | array of object, 1 to 16 items | yes |
| `environments[].name` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `defaultEnv` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |

**Answer (200 or 201).**

| Field | Type | Required |
| --- | --- | --- |
| `id` | string matching `^prj_[A-Za-z0-9_-]{1,60}$` | yes |
| `slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `defaultEnv` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `environments` | array of object | yes |
| `environments[].name` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `vars` | array of object | yes |
| `vars[].name` | string matching `^[A-Za-z_][A-Za-z0-9_]{0,127}$` | yes |
| `vars[].env` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `vars[].source` | one of `plain`, `item`, `unset` | yes |
| `vars[].value` | string | no |
| `vars[].item` | string matching `^[a-z][a-z0-9-]{1,63}$` | no |
| `vars[].field` | string, or null | no |

**Its own refusals**, beyond the [common ones](#common-refusals): [`conflict`](https://docs.mehen.dev/reference/errors.md#conflict), [`quota_exceeded`](https://docs.mehen.dev/reference/errors.md#quota_exceeded), [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

### `projects.get`

```http
GET /v1/platform/subjects/:subject/projects/:project
```

Reads a project and its variables, plain values included.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `project` | string matching `^prj_[A-Za-z0-9_-]{1,60}$` | yes |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `id` | string matching `^prj_[A-Za-z0-9_-]{1,60}$` | yes |
| `slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `defaultEnv` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `environments` | array of object | yes |
| `environments[].name` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `vars` | array of object | yes |
| `vars[].name` | string matching `^[A-Za-z_][A-Za-z0-9_]{0,127}$` | yes |
| `vars[].env` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `vars[].source` | one of `plain`, `item`, `unset` | yes |
| `vars[].value` | string | no |
| `vars[].item` | string matching `^[a-z][a-z0-9-]{1,63}$` | no |
| `vars[].field` | string, or null | no |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `projects.setVar`

```http
PUT /v1/platform/subjects/:subject/projects/:project/vars/:name
```

Sets one variable in one environment to a plain value or an item's field, or unsets it when neither is sent. `before` is the value last read; a stale one is a conflict. A plain value that looks like a secret is refused.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `project` | string matching `^prj_[A-Za-z0-9_-]{1,60}$` | yes |
| `name` | string matching `^[A-Za-z_][A-Za-z0-9_]{0,127}$` | yes |

**Body.**

| Field | Type | Required |
| --- | --- | --- |
| `env` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `before` | object, one of 2 shapes, or null | yes |
| `before.plain` | string, at most 4,096 characters | in its shape |
| `before.item` | string matching `^[a-z][a-z0-9-]{1,63}$` | in its shape |
| `before.field` | string matching `^[a-z][a-z0-9_]{0,63}$`, or null | in its shape |
| `plain` | string, at most 4,096 characters | no |
| `item` | string matching `^[a-z][a-z0-9-]{1,63}$` | no |
| `field` | string matching `^[a-z][a-z0-9_]{0,63}$` | no |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `id` | string matching `^prj_[A-Za-z0-9_-]{1,60}$` | yes |
| `slug` | string matching `^[a-z][a-z0-9-]{1,63}$` | yes |
| `defaultEnv` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `environments` | array of object | yes |
| `environments[].name` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `vars` | array of object | yes |
| `vars[].name` | string matching `^[A-Za-z_][A-Za-z0-9_]{0,127}$` | yes |
| `vars[].env` | string matching `^[a-z][a-z0-9-]{0,31}$` | yes |
| `vars[].source` | one of `plain`, `item`, `unset` | yes |
| `vars[].value` | string | no |
| `vars[].item` | string matching `^[a-z][a-z0-9-]{1,63}$` | no |
| `vars[].field` | string, or null | no |

**Its own refusals**, beyond the [common ones](#common-refusals): [`conflict`](https://docs.mehen.dev/reference/errors.md#conflict), [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

### `projects.remove`

```http
DELETE /v1/platform/subjects/:subject/projects/:project
```

Deletes a project. Its runs and their placeholders end.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |
| `project` | string matching `^prj_[A-Za-z0-9_-]{1,60}$` | yes |

**Body.** `{}`

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `deleted` | `true` | yes |

**Its own refusals**, beyond the [common ones](#common-refusals): [`frozen`](https://docs.mehen.dev/reference/errors.md#frozen).

### `audit.read`

```http
GET /v1/platform/subjects/:subject/audit
```

Reads the subject's audit chain a page at a time, every row, with its head and whether it verified. Every row names the platform.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Query.** Every value is a string on the wire.

| Parameter | Type | Required |
| --- | --- | --- |
| `cursor` | string matching `^[1-9][0-9]*$` | no |
| `limit` | integer, 1 to 200 | no |

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `events` | array of object | yes |
| `events[].seq` | integer, at least 1 | yes |
| `events[].at` | integer | yes |
| `events[].actor` | string | yes |
| `events[].action` | string | yes |
| `events[].target` | string, or null | yes |
| `events[].detail` | string | yes |
| `events[].prev` | string | yes |
| `events[].hash` | string | yes |
| `verified` | boolean | yes |
| `next` | string matching `^[1-9][0-9]*$`, or null | yes |
| `filtered` | boolean | yes |
| `head` | object, or null | yes |
| `head.seq` | integer, at least 1 | yes |
| `head.hash` | string | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `workspace.freeze`

```http
POST /v1/platform/subjects/:subject/freeze
```

Freezes the subject's workspace: nothing is used or changed until it is unfrozen.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Body.** `{}`

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `frozen` | `true` | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).

### `workspace.unfreeze`

```http
POST /v1/platform/subjects/:subject/unfreeze
```

Unfreezes the subject's workspace.

**Path.**

| Parameter | Type | Required |
| --- | --- | --- |
| `subject` | string matching `^[A-Za-z0-9_-]{8,128}$` | yes |

**Body.** `{}`

**Answer (200).**

| Field | Type | Required |
| --- | --- | --- |
| `frozen` | `false` | yes |

**Its own refusals:** none beyond the [common ones](#common-refusals).
