Appearance
Nodes HTTP API
This is the reference for the /v1/nodes HTTP surface. It maps two operations to their OpenAPI schemas, auth and ReBAC gates, audit emission, and closed Problem.code taxonomies: ListNodes, the cursor-paginated fleet listing with a per-row ReBAC filter, and GetNodePeerPSK, the NSK-authenticated per-Node fetch that serves a tunnel's pairwise WireGuard edge PSK rewrapped under the calling Node's NSK. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract. The cursor + limit + domain_id pagination idiom is established by the sibling tenancy Projects surface — see ./projects.md for the design rationale this surface inherits verbatim.
An operator dashboard is the canonical consumer: every row in a fleet-overview table renders a /nodes/{id} link to the per-Node detail page so an operator who spots a misbehaving Node in the listing reaches the per-Node read surfaces (/v1/nodes/{id}/state, /v1/nodes/{id}/events, /v1/nodes/{id}/reachability) in one click.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| GET | /v1/nodes | ListNodes | per-row node#read filter | node.list (page-level row) | (none) | n/a |
| GET | /v1/nodes/{id}/peers/{peer_node_id}/psk | GetNodePeerPSK | NSK node-id equality (no ReBAC round-trip) | fetch_edge_psk (granted / denied) | (none) | 60-byte response |
ListNodes.limitquery parameter is clamped at the handler to[1, 200]with default50. Out-of-range values surface as400 invalid_limitrather than silently rounding the request, so misconfigured clients fail loudly.ListNodes.cursoris opaque, HMAC-signed by the server (the codec is theNodeListCursorCodecport wired at the composition root); a tampered cursor surfaces as400 invalid_cursor. A nil codec is tolerated in unit-test composition roots and behaves as identity passthrough.ListNodes.domain_idis an optional UUID filter scoping the page to a single parent Domain. Omitting the parameter widens to the cross-Domain listing the caller is authorised to see; a zero UUID is rejected as400 invalid_domain_filter.- The handler ships behind a fail-closed scaffold gate — until the composition root wires both
NodeListReaderandNodeListAuthzChecker, the listing surface returns501 nodes_not_provisionedso the surface is either fully wired or fully off.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| ListNodes | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered or malformed → 400 invalid_cursor. |
| ListNodes | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
| ListNodes | domain_id (query) | string (uuid) | no | Optional parent-Domain filter. Zero UUID → 400 invalid_domain_filter. |
Schemas
NodeSummary
Hydrated summary projection of a Node aggregate. The shape is shared by ListNodes; richer per-Node read surfaces (events, state, heartbeat, reachability) live under /v1/nodes/{id}/... and project their own response shapes. The kind field is intentionally modelled as an open string rather than an enum so a future Node kind addition does not require a contract version bump.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (uuid) | yes | Node identifier (UUIDv7). |
name | string | yes | Human-readable Node name (trimmed at the aggregate). The handler emits an empty string until a future schema change populates the field on the Node aggregate (see internal/transport/http/v1/handlers/nodes_list.go toNodeSummary). |
domain_id | string (uuid) | yes | Parent Domain identifier (UUIDv7). |
kind | string | yes | Node kind discriminator. Today the cluster recognises vm, bridge, and worker; the field is an open string. The handler emits an empty string until a future schema change joins Resource (which owns the kind) into the listing query. |
created_at | string (date-time) | yes | Aggregate creation timestamp (UTC). |
NodeList
Page of Nodes returned by GET /v1/nodes. The window is computed by the persistence layer in id-order; per-row visibility is layered on top, so the items array is the subset the caller is authorised to see — len(items) < limit is NOT a reliable end-of-stream signal. Consult next_cursor instead.
| Field | Type | Required | Notes |
|---|---|---|---|
items | array<NodeSummary> | yes | Nodes in the current page (post per-row visibility filter). |
next_cursor | string (nullable) | no | Opaque HMAC-signed continuation. Absent or null at end-of-stream. |
ReBAC contract
| Operation | Relation evaluated | Subject | Object | On denial |
|---|---|---|---|---|
| ListNodes | per-row read | resolved principal projected onto user:<uuid> / serviceaccount:<uuid> / apitoken:<uuid> | node:<id> for each candidate row | row filtered out; per-row denial NOT audited individually (page-level audit row carries item_count + node.list.authz_errors) |
The list-shape authz seam takes a canonical (subject, relation, object) tuple via the NodeListAuthzChecker port (see internal/transport/http/v1/handlers/nodes_list_authz.go) rather than the (principal, relation, object) shape the events/state/heartbeat surfaces' RelationChecker takes. The handler projects the resolved authn.Principal onto the SpiceDB subject string at the boundary so the per-row Check call is symmetric with the projects sibling.
Per-row authz failure mode is fail-closed with observability: ErrNodeListPermissionDenied (or any error wrapping it) drops the row silently; any OTHER error class drops the row, emits a slog.WarnContext breadcrumb, and increments the audit row's node.list.authz_errors counter so an operator can see drift between the persistence-layer page and the post-filter response.
Error taxonomy
The closed Problem.code set this surface emits, exactly as defined in internal/transport/http/v1/handlers/nodes_list.go. The Origin column names the layer (handler / reader / transport) so a future maintainer knows where to grep when the code changes.
| HTTP status | Problem.code | Origin | Trigger |
|---|---|---|---|
| 400 | invalid_limit | handler | List limit query parameter outside [1, 200]. |
| 400 | invalid_cursor | handler | List cursor failed NodeListCursorCodec.Decode or did not parse as a tenancy.ID after decoding. |
| 400 | invalid_domain_filter | handler | List domain_id query parameter was a zero UUID. |
| 401 | unauthorized | handler | No resolved principal (or authn.KindUnknown). |
| 403 | (PermissionDenied) | transport | Surface-level ReBAC denial (separate schema, not Problem). Per-row denials never reach this gate — they are dropped from items instead. |
| 500 | internal | handler / reader | NodeListReader.List returned a non-nil error, or NodeListCursorCodec.Encode failed on the next-page cursor. The underlying error text is logged via slog.ErrorContext and NEVER leaks to the wire. |
| 501 | nodes_not_provisioned | handler | Scaffold fail-closed gate: NodeListReader OR NodeListAuthzChecker is not wired in this build. |
Every Problem detail on this surface is a sentence written in the transport layer. The underlying error chain never reaches the wire; it appears only in the log record for the same failure. Both the body and that record carry the same correlation_id, so an operator holding a detail from a caller can pivot to the cause by that id.
Audit & outbox contract
ListNodes is a read-only surface, so it never writes an outbox event. The handler emits exactly one page-level audit row per successful response via the wired AuditSink; sink errors are NOT propagated to the caller — a flaky audit backend cannot turn a successful read into a 5xx. Mirrors the projects sibling's emitListAudit posture.
| Operation | Outcome | Audit relation | Audit outcome | Outbox event |
|---|---|---|---|---|
| ListNodes | success | node.list (page-level row) | granted | (none) |
| ListNodes | 401 / 400 / 500 / 501 | (none — pre-handler arms exit before the audit emission) | n/a | (none) |
The audit row stamps the canonical platform singleton (platform:plexsphere) as its Object because the list operation is cross-Project and optionally cross-Domain — there is no single aggregate to address. The caveat_context map carries:
item_count— the number of rows in the response after per-row visibility filtering, NOT the persistence-layer page size. A reader can compareitem_counttolimitto detect heavy filtering.node.list.authz_errors— present only when the per-row authz filter dropped at least one row due to an infrastructural fault (transport flake, schema drift). Absent when every drop was a cleanErrNodeListPermissionDenied.
The audit row's Subject is the resolved principal's UUID and Reason is the canonical string "node listing read". Per-row denials are NOT audited individually — the page-level row is the audit anchor for the entire listing call.
Per-Node state pull dispatch blocks
The per-Node reconciliation pull GET /v1/nodes/{id}/state returns the NodeStateSnapshot envelope. Two of its always-present array blocks carry operator-dispatched work to the addressed Node — the pull is the baseline delivery channel for both. This section maps the two blocks; the authoritative contract is the OpenAPI spec and the bounded-context reference at ../../contexts/mesh/reconciliation-pull.md.
| Block | Carries | Notable fields | Ordering | Drains when |
|---|---|---|---|---|
executions | Pending action dispatches addressed to the Node whose per-target status is live (pending / ack / started) and whose deadline is unexpired. | execution_id, action, type (builtin / hook), optional parameters, status, requested_at, absolute expires_at. | requested_at, then execution_id. | The target reaches a terminal status via the execution callback. |
sessions | Live mediated sessions targeting a Resource the Node provisions. | session_id, jti (equals the session id), kind (ssh / k8s / tcp), target, expires_at, optional idle_timeout_seconds. | issued_at, then session_id. | The Session is revoked or hard-expires. |
Both blocks are always present — [] when the Node has none, never null. Neither carries a callback_url: the agent derives its callback path from its configured base. The two node-facing callbacks that drain the blocks are NSK-authenticated and share the nsk_node_mismatch403 gate (the resolved NSK Node must equal the path id):
POST /v1/nodes/{id}/executions/{execution_id}advances an ActionInvocation through its closed status state machine (pending → ack → started → {succeeded, failed, cancelled, timeout}).POST /v1/nodes/{id}/sessions/{session_id}records one per-kind session activity and touches the session's last-active timestamp. It answers204on success,409session_already_revokedfor a dead Session,404session_not_foundfor an unknown one, and400for a body that is not the exactly-one-ofssh|k8s|tcpactivity request.
Edge-PSK fetch
GetNodePeerPSK serves the WireGuard preshared key for the tunnel between the addressed Node and a named peer, rewrapped under the calling Node's NSK. WireGuard requires the same preshared key on both ends of a tunnel, but the control plane persists one wrapped PSK per Node; the server derives the per-tunnel edge key from the two endpoints' stored per-Node PSKs via HKDF-SHA256 — both edges derive a byte-identical value — and serves it rewrapped so that only NSK-wrapped ciphertext ever crosses the wire. The derivation, the mapping decision, and the threat model live in the bounded-context reference at ../../contexts/mesh/peers.md.
Authentication
GetNodePeerPSK is NSK-only — it does not accept the operator bearer scheme. The Node authenticates against its per-Node NSK plaintext in the Authorization: Bearer header, exactly like the heartbeat, endpoint-observation, and secret-fetch surfaces. The NSK arm applies an equality gate with no ReBAC round-trip: the authenticated Node must equal the path id, so a cross-Node NSK is refused 403 node_id_mismatch and a revoked one 401 nsk_revoked. The gate is fail-closed — there is no operator credential that can reach another Node's edge keys.
Path parameters
| Parameter | Type | Required | Notes |
|---|---|---|---|
id (path) | string (uuid) | yes | Node identifier (UUIDv7) of the addressed edge endpoint. Must equal the NSK-authenticated calling Node. |
peer_node_id (path) | string (uuid) | yes | Node identifier (UUIDv7) of the peer at the other end of the edge. Must differ from id — no Node builds a tunnel to itself. |
Authorization (header) | string | yes | Bearer <NSK plaintext> — the per-Node Node Secret Key. |
Success response
A 200 carries the rewrapped edge-PSK envelope as application/octet-stream plus two required response headers:
| Header | Meaning |
|---|---|
X-Plexsphere-PSK-KID | The NSK key id used to wrap the envelope, so the agent can pick the right NSK to unwrap with during a key-rotation overlap window. |
X-Plexsphere-PSK-Epoch | The PSK epoch the envelope is bound to — see Freshness below. Required to open the envelope at all. |
Cache-Control | Always no-store — the ciphertext envelope must not be retained by any intermediary or client cache. |
The body is the raw envelope <12-byte nonce> || <ciphertext + 16-byte GCM tag> — exactly 60 bytes for the 32-byte edge PSK. The agent recovers the edge PSK byte-for-byte with AES-256-GCM-Open under its NSK, then programs it as the WireGuard preshared key for the tunnel to peer_node_id. The body is opaque ciphertext and is never logged.
The envelope is sealed with additional authenticated data that the agent must supply verbatim to AES-256-GCM-Open:
text
plexsphere/mesh/edge-psk/v1|<id>|<peer_node_id>|<epoch>where <id> and <peer_node_id> are the two canonical lowercase UUIDs from the request path, in that order, and <epoch> is the X-Plexsphere-PSK-Epoch header value used verbatim. Passing the wrong value — or none — fails the tag check.
The binding is what keeps this envelope from being interchangeable with the one GET /v1/nodes/{id}/secrets/{name} returns: both are sealed under the same NSK with the same layout, so without the AAD an attacker positioned to swap response bodies could answer a PSK fetch with a 32-byte secret value and have the agent install it as a live WireGuard preshared key. The secret-fetch surface seals with no AAD; this one seals with the edge binding, so neither opens in the other's place.
Freshness
X-Plexsphere-PSK-Epoch names the exact pair of PSK issuances the edge key was derived from — the caller's PSK issued_at, then the peer's, as <caller>|<peer> in fixed-width UTC (2006-01-02T15:04:05.000000000Z).
The agent must refuse an envelope whose epoch is older than the one already in force for that edge. A mesh key rotation does not rotate the NSK, so without this rule an envelope captured before a rotation opens cleanly after it: an attacker able to substitute a response body could answer a post-rotation fetch with the pre-rotation envelope and have the agent reinstall the key the rotation was meant to retire. Fed to both endpoints the tunnel comes back up on the retired key; fed to one it wedges permanently, because the agent retries, gets the same stale body, and has no other signal that anything is wrong.
The epoch is bound into the GCM tag rather than merely advertised, which is what makes the header trustworthy: an attacker replaying an old body cannot pair it with a fresher epoch claim, and a fresh body does not open under a stale one. The fixed-width format exists so the comparison is a plain string compare — byte order equals chronological order, so no timestamp parsing is needed.
Note the epoch is built from the PSK rows' issued_at, not their (kid, wrap_key_version). That pair names the per-Domain wrap key, and a rotation re-wraps the re-issued PSK under the same active wrap key — so it does not change across a rotation and would be useless as a freshness signal.
Throttling
Fetches are admitted against a per-Node token bucket, keyed on the NSK-authenticated Node rather than the source address — a fleet behind one NAT egress address is therefore never throttled collectively. The burst is sized to admit a full-mesh re-fetch pass in one go, which is the largest legitimate spike the fetch-on-change contract produces, so a well-behaved agent never meets the limit.
The budget exists because this surface writes an audit row on every request, refusals included, and each append serializes on the Domain's hash-chain head. An unthrottled caller holding one valid NSK could otherwise loop against random peer_node_id values and turn cheap requests into unbounded permanent writes in an append-only, erasure-resistant forensic store while starving that Domain's other audit writers. A throttled request is refused before the audit sink is reached and therefore writes no row of its own.
Error taxonomy
The closed Problem.code set this surface emits. Every body — the 403 paths included — is a plain Problem, matching the convention every other NSK-authenticated node surface follows: the denial comes from the NSK path-binding rather than the ReBAC authorizer, so there is no relation path to report.
| HTTP status | Problem.code | Trigger |
|---|---|---|
| 401 | unauthorized / nsk_invalid / nsk_revoked | The NSK in the Authorization: Bearer header is missing or malformed (unauthorized), cannot be resolved (nsk_invalid), or resolves to a revoked NSK (nsk_revoked). |
| 403 | node_id_mismatch | The NSK authenticates but resolves to a different Node than the path id; a leaked NSK cannot be replayed against a sibling Node. |
| 403 | node_deregistered | The NSK authenticates and matches the path id, but the calling Node has been deregistered. Terminal on purpose — see the retry rule below. |
| 404 | peer_not_found | peer_node_id names no live, non-deregistered peer of the calling Node's Domain — also returned when peer_node_id equals id. Unknown, soft-deregistered, and foreign-Domain all collapse onto this code, so the surface is not a cross-Domain oracle. |
| 409 | psk_not_ready | Either endpoint's live PSK row has not landed yet — the transient post-registration window before the anchor consumer assigns a per-Node PSK. The agent retries on its next reconcile. |
| 429 | per_node_rate_limited | The calling Node exhausted its per-Node fetch budget. The response carries a required Retry-After header (seconds); the agent waits that long and resumes. |
| 501 | psk_delivery_not_provisioned | The edge-PSK delivery bundle is not wired into the composition root in this build; log scrapers alert on the deferred-wiring state. |
| 500 | internal | Server-side failure path; the wire body stays generic and no backend or driver text is interpolated into it. |
Cross-references
./projects.md— sibling reference that established the cursor + limit + domain_id pagination pattern, the per-rowreadReBAC filter, and the page-level audit row contract this surface inherits.../../contexts/identity/rebac.md— relation graph behindnode#read.../api/index.md— platform-wide/v1HTTP surface map.../../../api/openapi/plexsphere-v1.yaml— authoritative OpenAPI 3.1 contract; this doc is a map, not a duplicate.../../../internal/transport/http/v1/handlers/nodes_list.go— handler body; clamp / cursor / domain-filter arms, per-row authz filter, page-level audit emission.../../../internal/transport/http/v1/handlers/nodes_list_authz.go—NodeListAuthzCheckerandNodeListCursorCodecport declarations plus theErrNodeListPermissionDeniedsentinel.../../../internal/transport/http/v1/handlers/nodes_list_deps.go—NodeListReaderport declaration and theErrNodeListNotFoundsentinel held for the futureGET /v1/nodes/{id}hydrator.