Skip to content

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

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
GET/v1/nodesListNodesper-row node#read filternode.list (page-level row)(none)n/a
GET/v1/nodes/{id}/peers/{peer_node_id}/pskGetNodePeerPSKNSK node-id equality (no ReBAC round-trip)fetch_edge_psk (granted / denied)(none)60-byte response
  • ListNodes.limit query parameter is clamped at the handler to [1, 200] with default 50. Out-of-range values surface as 400 invalid_limit rather than silently rounding the request, so misconfigured clients fail loudly.
  • ListNodes.cursor is opaque, HMAC-signed by the server (the codec is the NodeListCursorCodec port wired at the composition root); a tampered cursor surfaces as 400 invalid_cursor. A nil codec is tolerated in unit-test composition roots and behaves as identity passthrough.
  • ListNodes.domain_id is 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 as 400 invalid_domain_filter.
  • The handler ships behind a fail-closed scaffold gate — until the composition root wires both NodeListReader and NodeListAuthzChecker, the listing surface returns 501 nodes_not_provisioned so the surface is either fully wired or fully off.

Path & query parameters

OperationParameterTypeRequiredNotes
ListNodescursor (query)stringnoOpaque HMAC-signed continuation. Tampered or malformed → 400 invalid_cursor.
ListNodeslimit (query)integerno[1, 200], default 50. Out-of-range → 400 invalid_limit.
ListNodesdomain_id (query)string (uuid)noOptional 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.

FieldTypeRequiredNotes
idstring (uuid)yesNode identifier (UUIDv7).
namestringyesHuman-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_idstring (uuid)yesParent Domain identifier (UUIDv7).
kindstringyesNode 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_atstring (date-time)yesAggregate 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.

FieldTypeRequiredNotes
itemsarray<NodeSummary>yesNodes in the current page (post per-row visibility filter).
next_cursorstring (nullable)noOpaque HMAC-signed continuation. Absent or null at end-of-stream.

ReBAC contract

OperationRelation evaluatedSubjectObjectOn denial
ListNodesper-row readresolved principal projected onto user:<uuid> / serviceaccount:<uuid> / apitoken:<uuid>node:<id> for each candidate rowrow 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 statusProblem.codeOriginTrigger
400invalid_limithandlerList limit query parameter outside [1, 200].
400invalid_cursorhandlerList cursor failed NodeListCursorCodec.Decode or did not parse as a tenancy.ID after decoding.
400invalid_domain_filterhandlerList domain_id query parameter was a zero UUID.
401unauthorizedhandlerNo resolved principal (or authn.KindUnknown).
403(PermissionDenied)transportSurface-level ReBAC denial (separate schema, not Problem). Per-row denials never reach this gate — they are dropped from items instead.
500internalhandler / readerNodeListReader.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.
501nodes_not_provisionedhandlerScaffold 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.

OperationOutcomeAudit relationAudit outcomeOutbox event
ListNodessuccessnode.list (page-level row)granted(none)
ListNodes401 / 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 compare item_count to limit to 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 clean ErrNodeListPermissionDenied.

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.

BlockCarriesNotable fieldsOrderingDrains when
executionsPending 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.
sessionsLive 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 answers 204 on success, 409 session_already_revoked for a dead Session, 404 session_not_found for an unknown one, and 400 for a body that is not the exactly-one-of ssh | k8s | tcp activity 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

ParameterTypeRequiredNotes
id (path)string (uuid)yesNode identifier (UUIDv7) of the addressed edge endpoint. Must equal the NSK-authenticated calling Node.
peer_node_id (path)string (uuid)yesNode 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)stringyesBearer <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:

HeaderMeaning
X-Plexsphere-PSK-KIDThe 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-EpochThe PSK epoch the envelope is bound to — see Freshness below. Required to open the envelope at all.
Cache-ControlAlways 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 statusProblem.codeTrigger
401unauthorized / nsk_invalid / nsk_revokedThe NSK in the Authorization: Bearer header is missing or malformed (unauthorized), cannot be resolved (nsk_invalid), or resolves to a revoked NSK (nsk_revoked).
403node_id_mismatchThe NSK authenticates but resolves to a different Node than the path id; a leaked NSK cannot be replayed against a sibling Node.
403node_deregisteredThe NSK authenticates and matches the path id, but the calling Node has been deregistered. Terminal on purpose — see the retry rule below.
404peer_not_foundpeer_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.
409psk_not_readyEither 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.
429per_node_rate_limitedThe calling Node exhausted its per-Node fetch budget. The response carries a required Retry-After header (seconds); the agent waits that long and resumes.
501psk_delivery_not_provisionedThe edge-PSK delivery bundle is not wired into the composition root in this build; log scrapers alert on the deferred-wiring state.
500internalServer-side failure path; the wire body stays generic and no backend or driver text is interpolated into it.

Cross-references