Skip to content

Node mesh HTTP API

This is the reference for the mesh surface under /v1/nodes/{id}/... and the two domain-wide mesh routes under /v1/keys/rotate and /v1/domains/{domainId}/mesh/topology. It maps each operation to its OpenAPI schema, the per-call ReBAC relation that gates it, the closed Problem.code taxonomy, and the deferred-wiring posture that produces 501 until the production composition root threads every collaborator. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for the bounded-context narratives see ../../contexts/mesh/sse.md (signed event bus), ../../contexts/mesh/reconciliation-pull.md (canonical state snapshot), ../../contexts/mesh/reachability.md (heartbeat-driven projection), and ../../contexts/mesh/key-rotation.md (per-Peer mesh-key rotation lifecycle). For the cursor-paginated /v1/nodes listing surface (which carries the nodes tag, NOT mesh), see ../api/nodes.md.

Operations

MethodPathOperation IDAuthReBAC gateNotes
GET/v1/nodes/{id}/eventsGetNodeEventssession / API token / OIDCnode#node-agentSSE stream; Last-Event-ID resumes; 25-second keep-alive comment frame; X-Plexsphere-API-Version header pins the contract.
GET/v1/nodes/{id}/stateGetNodeStatesession / API token / OIDCnode#node-agentReturns the canonical NodeStateSnapshot; reuses the SSE relation so any subscriber can also pull.
POST/v1/nodes/{id}/heartbeatPostNodeHeartbeatNSK Bearerthe NSK is asserted to belong to the addressed NodeAdvances the reachability state machine; returns accepted_at + reconcile + rotate_keys hints.
PUT/v1/nodes/{id}/endpointPutNodeEndpointNSK Bearerthe NSK is asserted to belong to the addressed NodeRecords a NAT-observed Peer endpoint; returns accepted_at + stale_after. 4 KiB body cap.
PUT/v1/nodes/{id}/capabilitiesPutNodeCapabilitiesNSK Bearerthe NSK is asserted to belong to the addressed NodeRecords the per-Node capability-manifest snapshot (binary_version, binary_checksum, optional ssh_host_key_fingerprint, optional declared hooks); the 200 carries accepted_at + fields_changed + host_key_changed. 32 KiB body cap.
POST/v1/nodes/{id}/integrity-violationsPostNodeIntegrityViolationsNSK Bearerthe NSK is asserted to belong to the addressed NodeIngests a batch of integrity-violation reports; returns 202 Accepted with accepted_at + violation_count. The recommended remediation action is reprovision. 32 KiB body cap.
GET/v1/nodes/{id}/reachabilityGetNodeReachabilitysession / API token / OIDCnode#node-agentReads the latest Reachability projection (healthy/stale/unreachable + last_heartbeat_at + changed_at).
POST/v1/nodes/{id}/keys/rotatePostNodeKeysRotatesession / API token / OIDCnode#node-operateOperator-triggered: records a pending peer_key_rotation row and appends one rotate_keys outbox event. Idempotent — a second trigger against an already-pending rotation returns the existing handle.
GET/v1/nodes/{id}/keys/rotate/previewGetNodeKeysRotatePreviewsession / API token / OIDCnode#node-operateNon-mutating dry-run; returns the affected-edge listing and estimated_duration_seconds so a renderer can drive a confirmation dialog. NO event, NO peer_key_rotation row, NO audit beyond the read-side ReBAC decision.
POST/v1/keys/rotatePostKeysRotateNSK Bearerthe NSK identifies the rotating Node; NO path {id} segmentCompletion leg: plexd submits the freshly-generated Curve25519 public key; the handler updates nodes.public_key, retires the old PSK, wraps a fresh PSK, and flips the pending peer_key_rotation row to completed — all in one transaction. 4 KiB body cap.
GET/v1/domains/{domainId}/mesh/topologyGetDomainMeshTopologysession / API token / OIDCdomain#domain-viewRead-side projection of the per-Domain peer graph — nodes and directed edges (mode = direct/relayed, optional relay_node_id). Mirrors the SSE peer-graph events one-for-one.

All eleven operations may surface as 501 until the production composition root supplies their collaborators. The 501 carries a specific Problem.code per surface so log scrapers can alert on the deferred-wiring state without parsing the instance:

Operation501 codeMissing collaborators
GetNodeEventssigned_event_bus_not_provisionedEventStream, NonceStore, SignatureVerifier, RelationChecker, NodeRepo
GetNodeStatesigned_event_bus_not_provisionedSnapshotProvider, RelationChecker, NodeRepo
PostNodeHeartbeatheartbeat_not_provisionedNSKValidator, ReachabilityRepo, Clock
PutNodeEndpointendpoint_not_provisionedEndpointRecorder, NSKResolver, NodeRepo, PeerLookup
GetNodeReachabilityreachability_not_provisionedReachabilityRepo, RelationChecker, NodeRepo
PostNodeKeysRotatenode_keys_rotate_not_provisionedRotationRequester, RelationChecker
GetNodeKeysRotatePreviewnode_keys_rotate_preview_not_provisionedRotationPreview projection, RelationChecker
PostKeysRotatekeys_rotate_not_provisionedRotationCompleter, NSKResolver, NodeRepo, PeerLookup
GetDomainMeshTopologymesh_topology_not_provisionedTopologyProjection, RelationChecker
PutNodeCapabilitiescapabilities_not_provisionedCapabilitiesRecorder, NSKResolver, NodeRepo
PostNodeIntegrityViolationsintegrity_violations_not_provisionedIntegrityViolationsRecorder, NSKResolver, NodeRepo

The roadmap is tracked in ../../architecture/mesh-event-bus-roadmap.md; the kind dev wires every collaborator and never returns the 501.

Authentication artefacts

The eleven operations split across two distinct auth chains:

  • Read and operator-action surfaces (events, state, reachability, keys/rotate trigger + preview, mesh topology) — accept the same first-success-wins triple as the rest of /v1: psk_… API token Bearer, OIDC JWT Bearer, or plexsphere_session cookie. The relevant ReBAC relation gates access (node-agent for the read surfaces, node-operate for the rotation trigger and its preview, domain-view for the topology snapshot); missing relation → 403, unknown id → 404 only after the authz gate passes (no id oracle).

  • NSK-authenticated surfaces (POST .../heartbeat, PUT .../endpoint, PUT .../capabilities, POST .../integrity-violations, POST /v1/keys/rotate) — use the per-Node Node Secret Key (NSK) plaintext as a Bearer credential. The heartbeat and endpoint handlers:

    1. Authenticate the caller against the NSK.
    2. Assert that the NSK belongs to the Node addressed by the path id. A leaked NSK from a sibling Node surfaces as 403 node_id_mismatch so it cannot be replayed across Nodes.
    3. Validate the body — PostNodeHeartbeat requires client_now within 60s of server now, binary_checksum decoding to a 32-byte SHA-256, and binary_version non-empty; PutNodeEndpoint requires reported_at within 60s of server now and endpoint parsing as a non-loopback host:port tuple in the 1..65535 port range.
    4. Persist the observation — the heartbeat advances the reachability state machine, the endpoint observation stamps the Peer aggregate.

    PUT .../capabilities and POST .../integrity-violations follow the identical contract: they authenticate the NSK, run the same defence-in-depth path-id double-check (a sibling Node's NSK surfaces as 403 forbidden), validate the body, then persist. A missing or malformed NSK on either surface is 401 unauthorized.

    POST /v1/keys/rotate carries NO path {id} segment — the rotating Node is identified solely by the NSK envelope it presents, so a missing or unresolved NSK surfaces as 401 without leaking a Node id either side of the boundary.

The NSK was issued (exactly once) by POST /v1/register — see ../api/bootstrap-tokens.md.

SSE stream contract (GetNodeEvents)

Each event frame on /v1/nodes/{id}/events carries:

FieldValue
id:JetStream stream sequence number for the envelope (numeric, monotonic).
event:Discriminator — currently node_state_updated; the README's 14-type taxonomy lands incrementally without breaking the wire.
data:The canonical signed envelope JSON produced by internal/signing/envelope.CanonicalBytes. The trailing signature: field is an ed25519 signature over the canonical bytes; consumers SHOULD verify it as a defence-in-depth measure even though the server has already verified it before emitting the frame.

Resume protocol: re-connect with Last-Event-ID: <numeric> set to the last sequence the client durably processed; the server replays from > last. Absent or empty header → tail from now (no historical backfill). A non-numeric Last-Event-ID is 400 invalid_last_event_id and the stream is NOT opened.

The server emits a :keep-alive\n\n SSE comment-frame every 25 seconds so idle proxies do not collapse the connection. Cache-Control: no-cache opts the response out of any intermediary caching layer.

Reconciliation snapshot contract (GetNodeState)

NodeStateSnapshot always carries four wire blocks — peers, policy, bridge, state/reports — even when the latter three are empty. plexd's reconcile loop diffs by field presence rather than absence, so later stories populate the empty blocks without changing the wire shape.

The peer projection is a single SQL round-trip ordered by node_id ASC so two consecutive pulls against the same ledger snapshot are byte-equal — plexd reduces redundant rewrites by hashing the response. The addressed Node itself is excluded from peers so plexd never programs a self-peer.

Reachability state machine

PostNodeHeartbeat advances the per-Node state machine the projection at GET /v1/nodes/{id}/reachability reflects:

StateTransitionTrigger
healthystale90s without a heartbeat
staleunreachable300s without a heartbeat (cumulative)
stale / unreachablehealthyaccepted heartbeat

The Reachability response carries state, last_heartbeat_at (null until the first heartbeat), and changed_at (always present, tracks the most recent transition).

Heartbeat hints

The 200 response on PostNodeHeartbeat carries two reconciliation flags both defaulting to false:

  • reconcile: true — the controller wants plexd to issue a fresh GET /v1/nodes/{id}/state because something has drifted.
  • rotate_keys: true — the NSK is nearing expiry or has been administratively flagged; plexd should re-register through the bootstrap-token surface.

Until later stories flip them, plexd treats both as no-ops.

Endpoint observation contract (PutNodeEndpoint)

PUT /v1/nodes/{id}/endpoint accepts a NAT-observed Peer endpoint from plexd and stamps it onto the Peer aggregate backing the addressed Node. The handler caps the request body at 4 KiB before the JSON decoder runs — an over-cap body surfaces as 413 endpoint_body_too_large. The EndpointRequest body carries:

  • endpoint — a host:port tuple. The host must be a routable (non-loopback) IPv4/IPv6 address and the port must fall in the RFC 6056 1..65535 range; otherwise 400 endpoint_unparseable.
  • nat_type — the NAT classification plexd observed.
  • reported_at — the agent-side observation timestamp. It must be within 60s of server now and no older than the per-Domain endpoint TTL window; both refusals share 400 endpoint_clock_skew (the audit-row reason disambiguates the drift arm from the stale arm).

The persistence write emits a peer_endpoint_changed outbox event when the (endpoint, port) tuple differs from the prior observation or transitions out of the stale window; an identical refresh only advances the freshness timestamp without emitting a wake-up. The 200 response carries accepted_at (server commit timestamp) and stale_after (accepted_at plus the per-Domain endpoint TTL); plexd schedules its next observation before stale_after so the sweeper never tombstones a live endpoint.

Mesh-key rotation (PostNodeKeysRotate, GetNodeKeysRotatePreview, PostKeysRotate)

A mesh-key rotation is a three-leg flow that lets an operator replace a Node's Curve25519 keypair without re-enrolling from scratch:

  1. TriggerPOST /v1/nodes/{id}/keys/rotate records a pending peer_key_rotation row and appends one rotate_keys outbox event in a single transaction. Re-trigger against an already-pending rotation returns the existing handle with already_pending: true and appends no second event; the operation is idempotent.
  2. Preview (optional)GET /v1/nodes/{id}/keys/rotate/preview is a non-mutating dry-run that returns the same peer_id / already_pending view the mutating trigger would, plus the list of affected peer edges and an estimated_duration_seconds. The dashboard's manual-rotation confirmation dialog and plexctl key rotate --preview consume this payload. NO event, NO peer_key_rotation row, and NO audit row beyond the read-side ReBAC decision are emitted.
  3. Completion — plexd, on receiving the rotate_keys command via its SSE event stream, generates a fresh Curve25519 keypair and submits the new public key to POST /v1/keys/rotate (NSK-Bearer authenticated, no path id). The handler updates nodes.public_key, retires the old pairwise PSK, wraps and inserts a fresh PSK, appends a peer_key_rotated outbox event, and flips the pending peer_key_rotation row to completed — all in one transaction.

The 200 body of PostKeysRotate carries the rotation id plus the (kid, wrap_key_version) reference of the re-issued PSK; it never carries PSK plaintext or ciphertext. An idempotent retry with the same key against an already-completed rotation returns the prior receipt and emits no second event.

For the per-Peer state machine and the SSE event roster the rotation emits, see ../../contexts/mesh/key-rotation.md.

Mesh-topology snapshot (GetDomainMeshTopology)

GET /v1/domains/{domainId}/mesh/topology returns the read-side projection of the per-Domain peer graph that backs the dashboard mesh-map view and the plexctl mesh topology CLI. nodes carries each anchored Node's mesh-IP and reachability; edges carries each pairwise relationship with its mode (direct or relayed), the handshake age, and the bridge Node currently serving as the relay fallback when one is assigned. The payload mirrors the SSE peer-graph events one-for-one so a renderer that consumes the live stream and a renderer that polls this pull converge on byte-identical state.

Capability manifest (PutNodeCapabilities)

PUT /v1/nodes/{id}/capabilities records the per-Node capability manifest plexd reports after it starts: the running binary_version, the binary_checksum (a 32-byte SHA-256), an optional ssh_host_key_fingerprint, and an optional list of declared_hooks. The handler caps the body at 32 KiB before the JSON decoder runs — an over-cap body surfaces as 413 capabilities_body_too_large. Each hook entry carries a name and a checksum; duplicate names (declared_hook_duplicate) and an over-long list (declared_hooks_too_many) are refused. The 200 response carries accepted_at, the fields_changed set, and a host_key_changed boolean so a renderer can highlight a rotated SSH host key without diffing the full manifest. The recorded manifest feeds the policy context's capability and integrity surfaces — see ../../contexts/policy/capabilities.md.

Integrity violations (PostNodeIntegrityViolations)

POST /v1/nodes/{id}/integrity-violations ingests a batch of integrity-violation reports plexd raises when an artifact's observed checksum or SSH host-key fingerprint diverges from the manifest the Node declared. The body carries a non-empty violations array (an empty array is 400 integrity_violations_empty; an over-long batch is 400 integrity_violations_too_many); each entry names a kind, the detected_by detector, the offending artifact_id, and a kind-specific checksum or host-key fingerprint. The handler caps the body at 32 KiB (413 integrity_violations_body_too_large). On success it returns 202 Accepted with accepted_at and violation_count — acceptance is asynchronous, and the recommended remediation action is always reprovision. The ingested violations drive the policy context's integrity-alert pipeline — see ../../contexts/policy/integrity.md.

Path & query parameters

OperationParameterTypeRequiredNotes
every /v1/nodes/{id}/… operationid (path)string (uuid)yesNode identifier (UUIDv7). Malformed → 400 invalid_node_id.
GetDomainMeshTopologydomainId (path)string (uuid)yesDomain identifier (UUIDv7).
GetNodeEventsLast-Event-ID (header)stringnoSSE replay cursor; non-numeric → 400 invalid_last_event_id.
PostNodeHeartbeat / PutNodeEndpoint / PutNodeCapabilities / PostNodeIntegrityViolations / PostKeysRotateAuthorization (header)stringyesBearer <NSK plaintext>.

Schemas

The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:

  • State pull: NodeStateSnapshot (with sub-schemas for the four wire blocks).
  • Heartbeat: HeartbeatRequest (client_now, binary_checksum, binary_version, nat_summary), HeartbeatResponse (accepted_at, reconcile, rotate_keys).
  • Endpoint: EndpointRequest (endpoint, nat_type, reported_at), EndpointResponse (accepted_at, stale_after).
  • Reachability: Reachability (state + last_heartbeat_at + changed_at).
  • Key rotation: KeysRotateRequest (new_public_key), KeysRotateResponse (rotation_id, kid, wrap_key_version), RotationTriggerResponse (rotation_id, peer_id, already_pending), RotationImpactPreview (node_id, peer_id, affected_peer_count, affected_edges, already_pending, estimated_duration_seconds).
  • Mesh topology: MeshTopology (domain_id, generated_at, nodes, edges) with sub-schemas for MeshTopologyNode and MeshTopologyEdge.
  • Capability manifest: CapabilityManifestRequest (binary_version, binary_checksum, optional ssh_host_key_fingerprint, optional declared_hooks), CapabilityManifestResponse (accepted_at, fields_changed, host_key_changed).
  • Integrity violations: IntegrityViolationsRequest (violations array — each carries kind, detected_by, artifact_id, and a kind-specific checksum or host-key fingerprint), IntegrityViolationsResponse (accepted_at, violation_count).
  • SSE envelope: the wire body is text/event-stream; the data: payload is the canonical JSON produced by internal/signing/envelope.CanonicalBytes. The OpenAPI document cannot describe the framed event-stream encoding directly.

Error taxonomy

All error responses (other than the SSE text/event-stream success body) use the shared Problem envelope (application/problem+json). The 403 path on PostNodeHeartbeat uses the PermissionDenied shape; on the read surfaces it is a plain Problem with code = insufficient_relation.

CodeStatusWhereMeaning
invalid_node_id400every operationMalformed Node UUID.
invalid_last_event_id400GetNodeEventsNon-numeric or negative Last-Event-ID header.
clock_skew400PostNodeHeartbeatclient_now drifts more than 60s from server now.
binary_checksum_empty400PostNodeHeartbeatbinary_checksum missing or not a 32-byte SHA-256.
binary_version_empty400PostNodeHeartbeatbinary_version missing or whitespace-only after trim.
malformed_heartbeat_request400PostNodeHeartbeatBody cannot be decoded as a HeartbeatRequest envelope.
endpoint_clock_skew400PutNodeEndpointreported_at drifts more than 60s from server now, or is older than the per-Domain endpoint TTL window.
endpoint_unparseable400PutNodeEndpointendpoint is not a valid host:port, the port is outside 1..65535, or the host is not a routable IPv4/IPv6 address.
malformed_endpoint_request400PutNodeEndpointBody cannot be decoded as an EndpointRequest envelope (invalid JSON, unknown field, missing required field).
nsk_revoked401PostNodeHeartbeat / PutNodeEndpointNSK in the Authorization: Bearer header is missing, malformed, or has been revoked.
insufficient_relation403read surfacesCaller lacks node#node-agent.
node_id_mismatch403PostNodeHeartbeat / PutNodeEndpointNSK authenticates but belongs to a different Node — replay defence.
node_not_found404read surfaces / PostNodeHeartbeatNode id not resolved (post-authz).
endpoint_peer_not_found404PutNodeEndpointNo live Peer row resolves for the authenticated Node (drained or never bound to a Peer).
endpoint_peer_gone410PutNodeEndpointThe target Peer was deregistered between the admission gates and the recorder's UPDATE — a narrow race.
endpoint_body_too_large413PutNodeEndpointRequest body exceeded the 4 KiB endpoint envelope cap.
malformed_keys_rotate_request400PostKeysRotateBody cannot be decoded as a KeysRotateRequest envelope.
keys_rotate_public_key_invalid422PostKeysRotatenew_public_key does not decode to exactly 32 bytes, or is the all-zero degenerate value.
keys_rotate_peer_not_found404PostKeysRotateNo live Peer row resolves for the authenticated Node (drained or never bound to a Peer).
keys_rotate_no_pending_rotation409PostKeysRotateNo pending peer_key_rotation row exists for the authenticated Node — the submission has no rotation to complete.
keys_rotate_body_too_large413PostKeysRotateRequest body exceeded the 4 KiB rotation envelope cap.
insufficient_relation403PostNodeKeysRotate / GetNodeKeysRotatePreview / GetDomainMeshTopologyCaller lacks node-operate (rotation trigger / preview) or domain-view (topology). Surfaced before the existence check.
node_not_found404PostNodeKeysRotate / GetNodeKeysRotatePreviewNode id does not match any Node in this Domain — post-authz only.
domain_not_found404GetDomainMeshTopologyDomain id does not match any Domain — post-authz only.
signed_event_bus_not_provisioned501GetNodeEvents / GetNodeStateEventStream / NonceStore / SignatureVerifier / RelationChecker / NodeRepo / SnapshotProvider not wired in this build.
heartbeat_not_provisioned501PostNodeHeartbeatNSKValidator / ReachabilityRepo / Clock not wired.
endpoint_not_provisioned501PutNodeEndpointEndpointRecorder / NSKResolver / NodeRepo / PeerLookup not wired.
reachability_not_provisioned501GetNodeReachabilityReachabilityRepo / RelationChecker / NodeRepo not wired.
keys_rotate_not_provisioned501PostKeysRotateRotationCompleter / NSKResolver / NodeRepo / PeerLookup not wired.
node_keys_rotate_not_provisioned501PostNodeKeysRotateRotationRequester / RelationChecker not wired.
node_keys_rotate_preview_not_provisioned501GetNodeKeysRotatePreviewRotationPreview projection / RelationChecker not wired.
mesh_topology_not_provisioned501GetDomainMeshTopologyTopologyProjection / RelationChecker not wired.
unauthorized401PutNodeCapabilities / PostNodeIntegrityViolationsNSK in the Authorization: Bearer header is missing, malformed, or revoked.
forbidden403PutNodeCapabilities / PostNodeIntegrityViolationsNSK authenticates but belongs to a different Node — the path-id double-check failed.
malformed_capabilities_request400PutNodeCapabilitiesBody cannot be decoded as a CapabilityManifestRequest envelope.
binary_checksum_invalid400PutNodeCapabilitiesbinary_checksum is missing or does not decode to a 32-byte SHA-256.
ssh_host_key_fingerprint_invalid400PutNodeCapabilitiesssh_host_key_fingerprint is present but malformed.
declared_hook_invalid400PutNodeCapabilitiesA declared-hook entry is malformed (empty name or bad checksum).
declared_hook_duplicate400PutNodeCapabilitiesTwo declared hooks share a name.
declared_hooks_too_many400PutNodeCapabilitiesThe declared-hook list exceeds the per-manifest maximum.
capabilities_node_not_found404PutNodeCapabilitiesNode id not resolved (post-authn).
capabilities_body_too_large413PutNodeCapabilitiesRequest body exceeded the 32 KiB capabilities envelope cap.
capabilities_not_provisioned501PutNodeCapabilitiesCapabilitiesRecorder / NSKResolver / NodeRepo not wired.
malformed_integrity_violations_request400PostNodeIntegrityViolationsBody cannot be decoded as an IntegrityViolationsRequest envelope.
integrity_violations_empty400PostNodeIntegrityViolationsThe violations array is empty.
integrity_violations_too_many400PostNodeIntegrityViolationsThe violations array exceeds the per-batch maximum.
integrity_violation_kind_invalid400PostNodeIntegrityViolationsA violation kind is not a recognised value.
integrity_violation_kind_mismatch400PostNodeIntegrityViolationsA violation's payload does not match its declared kind.
integrity_violation_detected_by_invalid400PostNodeIntegrityViolationsA violation detected_by is not a recognised detector.
integrity_violation_artifact_id_empty400PostNodeIntegrityViolationsA violation is missing its artifact_id.
integrity_violation_checksum_invalid400PostNodeIntegrityViolationsA violation checksum is malformed.
integrity_violation_host_key_fingerprint_invalid400PostNodeIntegrityViolationsA violation host-key fingerprint is malformed.
integrity_violations_node_not_found404PostNodeIntegrityViolationsNode id not resolved (post-authn).
integrity_violations_body_too_large413PostNodeIntegrityViolationsRequest body exceeded the 32 KiB integrity-violations envelope cap.
integrity_violations_not_provisioned501PostNodeIntegrityViolationsIntegrityViolationsRecorder / NSKResolver / NodeRepo not wired.
internal500every operationServer-side failure path.

Cross-references