Appearance
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
| 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 |
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 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.
| 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.
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.