Nodus
Reference

Message catalog

Every Nodus wire message: envelope, control, signaling, transfer, sync, snapshot, and error payloads.

This is the human-readable reference for every Nodus wire message. The packages/protocol zod schemas are the source of truth; this document is a language-neutral counterpart maintained alongside src/. Machine-readable JSON Schema equivalents are generated from the zod schemas via pnpm generate:schemas (see schemas/).

All messages share a common envelope before their type-specific payload:

{
  "type": "shard_upload",
  "schema_version": "1.0",
  "message_id": "uuid",
  "payload": { "...": "..." }
}

Field naming is snake_case on the wire throughout. TS consumers map to/from camelCase at the serialize/deserialize boundary.


Control Messages

register

Device/node identity announcement to the Relay, sent once when a device or node first connects. Used to establish presence and authorization.

FieldTypeRequiredNotes
account_idstringyesThe owning account
device_idstringnoSet when a device registers
node_idstringnoSet when a storage node registers
public_keystringyesX25519 public key, hex-encoded
capabilitiesarray<enum>no (default [])Values: storage, signaling, sync

heartbeat

Liveness ping. Minimal payload; drives Relay-side presence (§13 Redis presence). A node/device is marked absent if no heartbeat arrives within the configured window.

FieldTypeRequiredNotes
idstringyesThe node or device sending the heartbeat
timestampISO 8601 stringyesWhen the heartbeat was sent

node_auth_challenge

Relay → Node, first frame after the node opens /ws (§8). The nonce is an opaque cryptographically random value with a 30-second TTL.

FieldTypeRequiredNotes
noncestringyesChallenge to sign; single-use, 30s TTL

node_auth_response

Node → Relay, answering the challenge. The signature is Ed25519 over the exact challenge nonce bytes, made with the node's persistent identity key.

FieldTypeRequiredNotes
node_idstringyesThe node's stable id (hex public key)
signaturestringyesHex-encoded Ed25519 signature over the nonce

node_auth_result

Relay → Node, the outcome of challenge-response. message is human-readable; reason is a stable machine-readable code the Storage Node acts on (for example, it prints "Storage Node is not paired. Run: nodus node pair" for node_not_found instead of retrying a generic failure).

FieldTypeRequiredNotes
statusenumyesok or fail
messagestringnoHuman-readable detail; not for programmatic routing
reasonenumnonode_not_found (no storage_nodes row) or node_inactive (row exists but is not ACTIVE, e.g. REVOKED). Absent on success.

WebRTC Signaling Messages

These carry SDP/ICE payloads verbatim as opaque strings. The protocol layer does not validate SDP or candidate internals. from_peer/to_peer let the Relay route Path B signaling without inspecting payload contents (§5).

from_peer and to_peer are each either a device_id or a node_id.

webrtc_offer

FieldTypeRequiredNotes
from_peerstringyesOrigin peer
to_peerstringyesDestination peer
sdpstringyesSDP offer body (opaque)

webrtc_answer

FieldTypeRequiredNotes
from_peerstringyesOrigin peer
to_peerstringyesDestination peer
sdpstringyesSDP answer body (opaque)

webrtc_ice_candidate

FieldTypeRequiredNotes
from_peerstringyesOrigin peer
to_peerstringyesDestination peer
candidatestringyesICE candidate (opaque)

Transfer Messages

shard_upload

Carries metadata only — the actual encrypted shard bytes travel over HTTP (POST /buffer/upload) or a separate binary transport, referenced by transfer_id. This avoids base64-encoding up to 8 MB per shard in JSON (decision #2).

FieldTypeRequiredNotes
file_idstringyesLogical file identifier
version_numberinteger ≥ 1yesFile version this shard belongs to
shard_indexinteger ≥ 0yes0-based shard position in the file
hashstringyesBLAKE3 hex digest of the encrypted ciphertext
sizeinteger ≥ 0yesEncrypted payload size in bytes (wire size)
transfer_idstringyesTies this metadata to the binary transfer stream
target_nodestringnoNode that should receive the shard
source_devicestringnoDevice that initiated the upload

shard_ack

Acknowledges receipt/verification of a shard. Ties into the file state machine (§23: NODE_RECEIVING → NODE_VERIFIED).

FieldTypeRequiredNotes
file_idstringyesLogical file identifier
version_numberinteger ≥ 1yesFile version this shard belongs to
shard_indexinteger ≥ 0yes0-based shard position
statusenumyesreceived, verified, or failed
transfer_idstringyesCorrelates to the original shard_upload
error_messagestringnoHuman-readable reason when status is failed

pending_notify

Relay → Node notification that a buffered shard is waiting for pickup (Path C, §13 relay buffer lifecycle). Sent when a client uploaded a shard via POST /buffer/upload while the target node was offline. Includes a fetch_token so the node can fetch the shard bytes over HTTP. The token is bound to the targeted node, and redemption is authenticated with that node's X-Nodus-* request signature as well as the bearer token.

FieldTypeRequiredNotes
file_idstringyesLogical file identifier
version_numberinteger ≥ 1yesFile version this shard belongs to
shard_indexinteger ≥ 0yes0-based shard position
buffer_idstringyesRelay-assigned buffer identifier
fetch_tokenstringyesSingle-use bearer token for GET /buffer/fetch (10-min TTL, Redis GETDEL), bound to this node. Sent as Authorization: Bearer <token> alongside the node's X-Nodus-* signature headers; a token presented by a different node is refused with 403
from_devicestringyesDevice that uploaded the shard
hashstringyesBLAKE3 hex digest — node verifies after fetch
sizeinteger ≥ 0yesEncrypted payload size in bytes — node verifies after fetch

tombstone_ack

Node → Relay. Reports this node's progress on a tombstoned (soft-deleted) entity so the Tombstone view can show per-node status. Sent after applying a tombstone (deleted) and after permanently removing the data (purged). The node id is taken from the authenticated WS connection, never the payload.

FieldTypeRequiredNotes
entity_typeenumyesfile or folder
entity_idstringyesThe tombstoned entity's ID
statusenumyesdeleted or purged

purge_tombstone

Relay → Node. Ask the node to permanently remove a tombstoned entity's versions/shards/objects. The node replies with tombstone_ack{status:purged}.

FieldTypeRequiredNotes
entity_typeenumyesfile or folder
entity_idstringyesThe entity to purge

restore_tombstone

Relay → Node. Undo a tombstone: the node deletes its tombstone row so retained data is not purged at the original retention deadline.

FieldTypeRequiredNotes
entity_typeenumyesfile or folder
entity_idstringyesThe entity to restore

shard_fetch

Request to retrieve a shard from a peer or the Relay buffer.

FieldTypeRequiredNotes
file_idstringyesLogical file identifier
shard_indexinteger ≥ 0yes0-based shard position
transfer_idstringyesCorrelates the fetch to the binary delivery
sourcestringyesrelay_buffer, or a node_id

shard_delete

Request to delete a shard (e.g. post-transfer cleanup, or GC per §29a).

FieldTypeRequiredNotes
file_idstringyesLogical file identifier
shard_indexinteger ≥ 0yes0-based shard position
reasonstringnoInformational only; not parsed

Design A shard stream (shard_fetch_request → binary frames)

When the Relay must serve a browser download for a shard that lives on a Storage Node (no LAN host), it sends the node a shard_fetch_request envelope and the node answers with raw ciphertext outside the JSON envelope:

Relay → Node   shard_fetch_request { request_id, object_id }
Node  → Relay  shard_fetch_result  { request_id, object_id, status }
Node  → Relay  <binary frame>      [u8 version=1][u16be id_len][request_id][payload]  (0..n)
Node  → Relay  shard_fetch_done    { request_id }

A node may serve several requests concurrently over its single Relay socket, so binary frames from different fetches can interleave. Each binary frame therefore carries the routing request_id in a fixed header:

FieldTypeNotes
versionu81 — the framing version (SHARD_FRAME_VERSION)
id_lenu16beLength in bytes of request_id
request_idASCIIThe shard_fetch_request.request_id this chunk belongs to
payloadbytesRaw ciphertext slice (chunk size chosen by the node)

A frame whose version byte or length field is malformed, or whose request_id does not match a waiter assigned to the sending node, is dropped rather than misrouted. Relay and Storage Node must be upgraded together: the older per-connection framing is not supported (lockstep cutover).

A node must not send more bytes for one request_id than the shard it holds. The Relay records the encrypted shard's length in file_locations.size_bytes when the node reports the shard stored, uses it as the Content-Length of the browser download it is serving, and stops forwarding at it — so an oversized stream is cut short rather than proxied. The recorded length is a claim by the sending node, so it can only lower the Relay's limit; the ceiling is the configured MAX_SHARD_BYTES_MB plus per-shard encryption framing. The browser therefore sees a short read against a declared Content-Length, and its own shard-hash check rejects the result. A node that under-declares gets its own shard truncated.


Sync Messages

sync_hello

Initial cursor-exchange handshake (§18). Sent when a node (or client) comes online to initiate incremental sync.

FieldTypeRequiredNotes
node_idstringyesThe sending node
cursorsarray of cursorsyesPer-origin { origin_id, sequence }

sync_status

Response to sync_hello. Each cursor adds a count so the sender can gauge how far behind it is.

FieldTypeRequiredNotes
node_idstringyesThe responding node
cursorsarray of cursorsyesPer-origin { origin_id, sequence, known_count }

event_batch

Wraps one or more sync events (§15). See event-types.md for the complete event schema and payload shapes. This is where the "exact sync event schema" open item is finalized.

FieldTypeRequiredNotes
eventsarray of eventsyesEvents ordered by origin_sequence within each origin

Both Storage Nodes and (Phase 14) authenticated devices may send event_batch. Both are validated server-side on the same three rules: every event's origin_id must equal the session peer id (the device_id or the node_id), its type must be in that peer's whitelist, and its origin_sequence must be strictly greater than the locked per-origin cursor. A batch that violates any of them is rejected whole and applies nothing.

The two whitelists differ in one type. A device may not claim FILE_SHARD_STORED — only a node reports that it has durably stored a shard. Everything else is shared: the set of types that has a projection in the Relay.

The per-event handling differs. A device batch is all-or-nothing down to the projection, because a device is interactive and can re-derive its state from the last_origin_sequence in the rejection. A node batch skips an individual unprojectable event and applies the rest, because a node's outbox is a durable ordered queue: one permanently-invalid row at the head would otherwise stall every later event behind it forever. Each node event runs in a savepoint, so a hard error in one event rolls back only that event.

batch_ack

Acknowledges an event_batch. Node batches carry only applied_event_ids; device batches additionally carry the result fields.

FieldTypeRequiredNotes
batch_idstringnoReserved; not currently set by the Relay
applied_event_idsarray of stringsyesEvents applied (or already applied)
okbooleannoDevice batches: false = rejected wholesale, nothing applied
reasonstringnoDevice rejection reason (sequence_regression, origin_mismatch, event_type_not_allowed)
last_origin_sequenceinteger ≥ 0noDevice batches: server cursor to re-sync a stale local counter from

reconcile

Physical reconciliation signal (§21). Carries a content hash / manifest reference rather than full file lists — the receiver compares hashes and requests a full snapshot only on divergence.

FieldTypeRequiredNotes
node_idstringyesThe reconciliation source
content_hashstringyesBLAKE3 of the node's file manifest
checkpointinteger ≥ 0yesSequence checkpoint of this reconciliation

Snapshot Messages

snapshot_begin

Metadata for a full snapshot transfer (§20). The Relay validates the signature against the trusted node public key before accepting chunks.

FieldTypeRequiredNotes
snapshot_idstringyesSnapshot identifier
node_idstringyesSource node
snapshot_sequenceinteger ≥ 1yesThe node's own monotonic counter for this snapshot. Must be strictly greater than the newest sequence the relay has promoted for this node, or the snapshot is refused as stale_snapshot — promoting an older one would roll the account back over newer state. Signature verification cannot catch a replayed snapshot, because an old one is signed by the same key over its own content hash. The relay records the promoted value in the same transaction that swaps the account's state, so a duplicated snapshot_end cannot move it backwards.
total_chunksinteger ≥ 1yesNumber of chunks before snapshot_end
content_hashstringyesBLAKE3 of the full snapshot payload
signaturestringyesEd25519 over content_hash
data_schema_versionstringyesVersion of the data inside the snapshot
cursorsarray of cursorsyesPer-origin { origin_id, sequence } adopted as the account's sync_cursors on promotion

cursors is a sibling of the field that is signed and is therefore not covered by the signature. The Relay validates the map itself and refuses the snapshot — at snapshot_begin, and again before any of it is written — if an entry has an empty origin_id, a negative sequence, a duplicated origin_id, or a sequence above the highest origin_sequence the Relay has itself accepted from that origin. Origins the Relay holds no events for are not bounded: after a factory reset the event log is empty and the node is the only remaining authority for its own state. A rejected map aborts the whole promotion rather than clamping, because the cursors decide which events replay.

snapshot_chunk

Ordered chunk of snapshot data. Reassembled by chunk_index; retries reuse the same index for idempotency.

FieldTypeRequiredNotes
snapshot_idstringyesSnapshot identifier
chunk_indexinteger ≥ 0yes0-based reassembly order
record_type"file_version" | "folder" | "key_envelope" | "tombstone"yesChunks are homogeneous
recordsarrayyesUp to SNAPSHOT_CHUNK_MAX_RECORDS (1000) records of record_type

folder records (Phase 14 F1) carry folder_id, parent_folder_id, encrypted_name, and created_at, and let a Relay rebuild reconstruct the folder tree instead of leaving files with dangling parent references. key_envelope records (Phase 14 F2c) carry file_id, recipient_id, recipient_kind, and the opaque encrypted_key, so a rebuild preserves FEK envelopes.

snapshot_end

Completion marker. The Relay validates final_hash against the hash promised in snapshot_begin before promoting the rebuilt state.

FieldTypeRequiredNotes
snapshot_idstringyesSnapshot identifier
final_hashstringyesBLAKE3 of all chunk data concatenated
signaturestringyesEd25519 over final_hash

Error Message

A generic error/rejection envelope (decision #5). When a receiver cannot process a message, it responds with this instead of forcing every ack type to double as an error carrier.

FieldTypeRequiredNotes
correlation_idstringyesThe message_id of the message that caused the error
error_codeenumyessee error codes below
error_messagestringyesHuman-readable; not for programmatic routing
retryablebooleannoWhether the sender should retry

Error codes

validation_error, unknown_message_type, incompatible_version, not_found, rate_limited, internal_error, auth_failed.

Note the spelling of the last one: the wire value is auth_failed, not auth_failure. The relay has always sent auth_failed; auth_failure was a documentation error, and the ErrorCode enum now carries the relay's value under the AUTH_FAILURE key so the category still reads the same in code.

The snapshot path additionally uses these, all of them a retryable: false rejection of snapshot_begin:

CodeSent when
stale_snapshotsnapshot_sequence is not newer than the newest already promoted for this node. Replaying an old snapshot is refused because promoting it would roll the account back over newer state.
invalid_snapshot_sequencesnapshot_sequence is not a positive integer. A node's counter starts at 1.
invalid_cursor_mapThe snapshot's cursor map is not consistent with the relay's event log (see snapshot_begin).
rebuild_in_progressAnother rebuild is already streaming for this account. Retryable.

All of the above are in the ErrorCode enum in packages/protocol/src/errors.ts, which is closed, so a code missing from it makes a client that strictly validates ErrorPayloadSchema reject an envelope it should have understood. A relay test (errorCodesEmittedByRelay in services/relay/internal/handler) fails if the relay emits a code that enum does not list, so the two cannot drift apart. The storage node does not parse error envelopes, so it is unaffected either way.


HTTP APIs (outside the WS envelope)

A few endpoints are plain HTTP and do not use the envelope above. The bootstrap-pairing endpoints associate a first-time Storage Node with an account (§7b); the code is a one-time credential and never appears on the WebSocket.

POST /pairing/codes (authenticated)

Mints a one-time pairing code for the signed-in account. Takes no body (the session cookie is the credential). Only the SHA-256 hash of the normalized code is stored; the plaintext is returned exactly once.

201 Created response:

FieldTypeNotes
codestringNODUS-XXXX-XXXX; alphabet A-Z minus I/O plus 2-9
expires_atISO 8601 string15-minute TTL

POST /pairing/codes/redeem (open)

Consumes a code and registers the presenting node under the issuing account, in one transaction (open — the code is the credential). The consume is atomic and single-use; a rejected registration rolls back so the code is not burned. IP rate-limited. The body is capped at 16 KiB. The registered Ed25519 key is immutable per node_id: re-registering with the same key is idempotent (key, status and is_primary preserved); a different key yields node_key_mismatch. The consumed row records the redeeming node_id.

FieldTypeRequiredNotes
codestringyesThe plaintext pairing code
node_idstringyesNon-empty, ≤128 printable ASCII
public_keystringyesHex-encoded Ed25519 public key (32 bytes)

200 OK → { "status": "ok", "account_id": "...", "is_primary": bool }.

Failures use {"error": "<reason>"}:

Statuserror
404code_unknown
410code_expired, code_revoked
409code_consumed, node_owned_elsewhere, node_key_mismatch
400body/node_id/public_key validation
429rate_limit_exceeded

On this page