Skip to content

Nodes HTTP API

This is the reference for the /v1/nodes HTTP surface. It maps the ListNodes operation to its OpenAPI schema, ReBAC gate, audit emission, and the closed Problem.code taxonomy. 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.

The Fleet Overview dashboard (web/src/features/fleet/FleetOverview.tsx) is the canonical consumer: every row in the 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
  • 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 carries the (PX-0045, REQ-009) trailer (and REQ-008 on the scaffold-gate arms) so reviewers can grep production logs back to the originating requirement.

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.

Cross-references