# SSH Access contracts The [SSH Access context](../../services/metallo/contexts/ssh_access/README.md) answers the browser's #678 routes through the API. The routes and their JSON schemas are in [`http_api/`](http_api/ssh_access.paths.json); `pnpm generate:contracts` bundles them into the API's OpenAPI document under `/api/v1/ssh-access/sites`. ## HTTP Every route but the Site list names its Site; one Site per process, and any other Site answers the same public `404 not_found`. | Route | Operation | Success | | --- | --- | --- | | `GET /sites` | `listHpcSites` | `200` the member's Sites as `{id, name}` | | `GET /sites/{siteId}/access` | `getHpcAccess` | `200` read model | | `GET /sites/{siteId}/keys` | `listHpcKeys` | `200` key list | | `POST /sites/{siteId}/keys` | `submitHpcKey` | `201` key | | `POST /sites/{siteId}/keys/validate` | `validateHpcKey` | `200` preview | | `GET /sites/{siteId}/keys/{keyId}` | `getHpcKey` | `200` key | | `POST /sites/{siteId}/keys/{keyId}/revoke` | `revokeHpcKey` | `200` key | | `POST /sites/{siteId}/keys/{keyId}/resubmit` | `resubmitHpcKey` | `200` key | | `DELETE /sites/{siteId}/keys/{keyId}` | `deleteHpcKey` | `204` | | `GET /sites/{siteId}/projection` | `getHpcProjection` | `200` `application/x-protobuf` snapshot | `siteId` matches `[a-z][a-z0-9-]{0,62}`: the identifier the context configures, never free text. `GET /sites` lists only Sites where the actor's member has an account, so the page never builds a Site id itself. Commands require a UUID `Idempotency-Key`, repeated unchanged on retry; each attempt uses a fresh request ID. They take no `If-Match`. Writes require a configured `Origin`, and submission and validation require `application/json` (`415 unsupported_media_type`). The actor comes from the session or CLI key, never from the request. Reads return `Cache-Control: no-store`. Owner errors use #678's `{ "error": { "code", "message", "params"? } }`. A gated `403` lists every failing gate as `{ "errors": [{ "code", "message" }] }` in #678 order. `key_duplicate` carries `params.state` and `params.keyId`. Status codes: `400` `invalid_id`, `invalid_request`; `403` gates, `member_not_found`; `404` `key_not_found`; `409` `key_duplicate`, `key_limit_reached`, `key_state_conflict`, `submission_in_progress`, `idempotency_conflict`; `422` `key_private`, `key_malformed`, `key_type_unsupported`, `key_too_weak`, `key_name_invalid`; `503` `temporarily_unavailable`. API-level `401 unauthorised`, `403 origin_denied` and session `503` keep the API's `{ "code" }` shape. #678 asks for `500 internal_error` and `403 cross_origin`; the API's existing `503` and `origin_denied` apply instead. #678's OpenAPI attachment is unavailable, so the key check codes are this contract's own until the browser contract confirms them. A Site this process does not serve answers `404 not_found`, the same body for a foreign Site, an unknown Site and a route without one: the caller learns nothing about where other keys live. The context keeps `site_not_found` as an internal, auditable code. A `keyId` that belongs to another Site stays `key_not_found`. ## Messaging The request contract is Metallo's [`ssh_access/requests.proto`](../../services/metallo/contracts/requests/proto/pharos/requests/v1/contexts/ssh_access/requests.proto), on the shared [request transport](../../services/metallo/docs/epics/instantiate-and-run/request-transport.md) (contract version 1, context `ssh_access`, listed in [`owners.json`](../../services/metallo/contracts/requests/owners.json)). Queries carry the actor in `QueryMetadata`. Commands carry `CommandMetadata` with the `Idempotency-Key` as `command_id`, the actor as `actor_id` and `expected_version` 0, since #678 sends no version. The command's aggregate is the key: a new key is named by its command identity, other commands address the key in the path. Every member request also carries `site_id`: the API is its only producer, from the path the session was authorised against, and the process rejects anything but the Site it serves, so the field is an assertion rather than an authority. `list_sites` carries none and answers the Sites where the actor's member has an account. | Request | Reply | | --- | --- | | `get_access`, `list_keys`, `get_key` | The read model, the keys or one key; a typed `RequestError` such as `key_not_found` | | `validate_key` | The preview, or a `Failure` naming every failing gate or the held duplicate (`state`, `key_id`); other checks as a `RequestError` | | `submit_key`, `revoke_key`, `resubmit_key`, `delete_key` | The recorded `Outcome`; a gated rejection lists its gates in `Rejection.fields` | Command replies commit with the outcome and the reply relay publishes them. The API reads a changed key back with `get_key`, and names the held key of a refused duplicate through `validate_key`, which runs the same check. | Boundary | Value | | --- | --- | | Routing keys | `request.ssh_access.query`, `request.ssh_access.command` | | Owner queues | `ref.ssh_access.queries`, `ref.ssh_access.commands` (quorum, 15-second TTL, delivery limit 5) | | Quarantine | `ref.ssh_access..dead` through `ref.ssh_access.delivery` | | Replies | `pharos.replies`, `reply.ssh_access.` | `scripts/metallo_broker.py` provisions the queues and the `pharos_ssh_access` role from `owners.json`. The role reads only its two queues and publishes only replies and quarantine transfers. The generated API role may publish both routing keys and consume its own `ref.api.ssh_access.replies.` queues. ## Realtime [`pharos/realtime/v1/ssh_access.proto`](../realtime/proto/pharos/realtime/v1/ssh_access.proto) defines `MemberSshAccessProjection`, the bytes of a `Publication` with context `ssh_access` and kind `member_ssh_access`. One publication is addressed to each authorised actor's principal subject. `GET /projection` returns a `pharos.realtime.v1.ProjectionSnapshot` of the same bytes with each root's revision; the API forwards it unchanged. A member without keys has an empty snapshot. ## Site protocol The access agent's routes are declared in [`../http/site-ssh-access.v1.yaml`](../http/site-ssh-access.v1.yaml) and served by the Metallo Site API at `site./v1/site/ssh-access/`. The access agent is configured with the Site API origin only and appends the route itself; the Site API does not share browser API credentials or backend authority. Native requests do not enter a browser OIDC callback flow.