Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HTTP API

Overview

Framework: axum Swagger UI: /swagger-ui OpenAPI JSON: /api-docs/openapi.json Default bind: http://localhost:3000 (configurable via listen_api in TOML config) TCP backlog: 4096 (explicit, for high-RPS scenarios) TCP_NODELAY: enabled (disables Nagle's algorithm for low-latency responses) Keepalive idle timeout: 30 s (header_read_timeout -- closes idle connections cleanly) Concurrency limit: 2400 in-flight requests (tower ConcurrencyLimitLayer, derived from Little's law for 100-node deployment at 600 K peak RPS); public discovery endpoints have a separate 256-slot limit so unauthenticated calls cannot occupy the main budget HTTP server: manual accept loop with hyper_util::server::conn::auto::Builder (not axum::serve) to expose hyper's HTTP/1.1 keepalive tuning

Two-Layer Design

The API exposes both layers of the node architecture:

Layer 1 endpoints (node-enforced):

  • Message sending/reading: POST/GET /dialogs/{peer}/messages, POST/GET /groups/{chat_id}/messages
  • Single message by id: GET /messages/{msg_id}; edit/delete own message: PATCH/DELETE /messages/{msg_id}
  • Inbox: GET /conversations
  • Group administration: POST /groups/{chat_id}/ops, DELETE /groups/{chat_id}/membership
  • Group members: GET /groups/{chat_id}/members
  • Read progress: POST /dialogs/{peer}/messages/read, POST /groups/{chat_id}/messages/read
  • Identity: PUT /identity, GET /identity/{address}

Layer 2 fields (client-opaque, passed through):

  • msg_type (u8) in message send body -- node stores as-is
  • control (base64 string -> Vec) in DM/group control endpoints -- opaque blob
  • Identity stored in dedicated identity CF via PUT/GET /identity endpoints (see below)

Channel semantics (normative): POST .../messages (text) is the only transport for user content -- literal text or a client-format envelope (E2EE ciphertext in base64 is still a string). POST .../messages/control (msg_type + control) carries protocol / service structures only (attachments metadata, MLS handshake). Clients MUST NOT introduce new control msg_types for user content. See CLIENT_GUIDE.md for the text-prefix convention that marks client formats inside text.

A client can use Layer 1 alone for plaintext messaging. Layer 2 enables any client-side protocol (E2EE, key exchange, etc.) via opaque fields. See ARCHITECTURE.md for the full two-layer description.

Authentication

All endpoints require ECDSA signature-based authentication via headers -- except the two public discovery endpoints (GET /node/info, GET /network/nodes), which are unauthenticated: clients call them during bootstrap before they have anything to sign, and infrastructure health checks cannot produce signatures.

HeaderDescription
X-UserSender's Ethereum-style address (hex, 0x-prefixed, 20 bytes)
X-TsTimestamp in milliseconds (must be within +/- 30 seconds of server time)
X-NodeBase58-encoded PeerId of the target node
X-SigECDSA signature (65 bytes hex: r[32] || s[32] || v[1])
X-Sig-VersionMust be "p2p-mes-v1"

Signature Verification

  1. Build canonical string-to-sign:

    p2p-mes-v1
    METHOD:{METHOD}
    PATH:{path}
    QUERY:{canonical_query}
    BODY:{canonical_body}
    TS:{ts_ms}
    NODE:{node_id}
    
  2. Canonicalization rules:

    • Query params: URL-decoded, sorted by key then value, percent-encoded
    • JSON body: flattened to key=value pairs (nested objects use dot notation, arrays use []; an object inside an array is not flattened -- it becomes one pair holding its compact JSON with alphabetically sorted keys, see CRYPTO.md), sorted, percent-encoded
    • Form body: parsed, sorted, percent-encoded
    • Binary body: raw={hex}
  3. Compute msg_hash = Keccak256(string_to_sign)

  4. Recover public key from ECDSA signature (tries both recovery IDs v=0 and v=1)

  5. Derive address: Keccak256(pubkey_uncompressed[1..])[12..32]

  6. Compare derived address with X-User claim

Verification Checks

  • X-Sig-Version must be "p2p-mes-v1"
  • |now - X-Ts| <= 30 seconds
  • X-Node must match this node's PeerId
  • Recovered address must match X-User

Endpoints

POST /dialogs/{peer}/messages

Send a direct message.

Path params:

  • peer -- recipient's address (hex 0x-prefixed, 20 bytes)

Request body:

{
  "text": "Hello, world!"       // 1-45056 bytes of UTF-8 (44 KiB), byte-counted
}

Response 200:

{
  "chat_id": "0x...",           // 32 bytes hex
  "msg_id": "0x...",            // 32 bytes hex
  "ts": 1699900000000           // Originator wall-clock at send time (= MsgV1.origin_wall_ts)
}

Notes:

  • chat_id is computed deterministically: BLAKE3("p2p-mes:chat:dm:v1:" || min(sender, peer) || max(sender, peer))
  • Message is published to gossip immediately, response returns before DB commit
  • msg_type = 0 (regular text message)
  • text is the only transport for user content, encrypted or not; the 44 KiB cap is sized so a 32 KiB payload fits after base64 (+33%) plus a client format prefix. The cap counts bytes, not chars

POST /dialogs/{peer}/messages/control

Send a control message carrying a protocol / service structure (attachments metadata, MLS handshake).

Path params:

  • peer -- recipient's address (hex 0x-prefixed)

Request body:

{
  "msg_type": 10,               // 1-255 (0 is reserved for regular text)
  "control": "pGplbmNyeXB0aW9u"  // base64-encoded CBOR payload; max 32 KiB (<= 43692 base64 chars)
}

Response 200:

{
  "chat_id": "0x...",
  "msg_id": "0x...",
  "ts": 1699900000000           // Originator wall-clock at send time
}

Notes:

  • text is set to empty string for control messages
  • The control payload is opaque to the node -- passed through as-is
  • msg_type is client-defined opaque u8 (node does not interpret it); known conventions: 10 = attachments, 20 = MLS Welcome, 21 = MLS Commit
  • Control is not a transport for user content -- encrypted user messages ride text on the plain endpoint (see CLIENT_GUIDE.md)

GET /conversations

List user's chats (inbox) with unread count.

Query params:

  • limit -- max results (1-1000, default 50, capped at 500)
  • after -- pagination cursor (hex-encoded raw key from previous response)

Response 200:

{
  "items": [
    {
      "chat_id": "0x...",
      "kind": { "type": "dm", "peer": "0x..." },
      "last_ts": 1699900000000,
      "last_sender": "0x...",
      "unread": 3,
      "cursor": "0x..."
    }
  ],
  "next_after": "0x..."         // null when the page was not full (no continuation cursor)
}

Notes:

  • Results sorted by most recent first (reverse timestamp in RocksDB key)
  • kind is one of: {"type": "dm", "peer": "0x..."}, {"type": "group", "title": "..."}, {"type": "channel", "title": "..."}. channel is a reserved type: there are no channel create/post/subscribe endpoints yet, so clients currently encounter only dm and group
  • kind.title for groups is the immutable creation title (see POST /groups/{chat_id}/ops); null for unnamed groups. The node hydrates it from chats_meta when writing the inbox entry, so it is present regardless of which node stored the triggering message
  • For dm chats, kind.peer here is viewer-relative: the node rewrites each user's inbox entry to hold the other participant, so the caller always sees their interlocutor. This is deliberately different from the kind embedded in msg_cbor (wire messages), where d.peer is the original recipient fixed by the sender -- see the notes on GET /dialogs/{peer}/messages
  • unread = last_seq - last_read_seq (from user_read_progress CF)
  • The response carries no content preview by design: chat-list lines are the client's job, rendered from the local message cache (an E2EE envelope is opaque to the node anyway; see CLIENT_GUIDE.md)

GET /dialogs/{peer}/messages

Get DM message history with a specific peer.

Path params:

  • peer -- peer's address (hex 0x-prefixed)

Query params:

  • from -- start timestamp in ms (default 0)
  • to -- end timestamp in ms (optional, default unlimited)
  • after -- pagination cursor (hex-encoded RocksDB key)
  • limit -- max results (1-1000, default 100)
  • reverse -- scan direction (default false). false walks oldest -> newest starting at from; true walks newest -> oldest starting at to, i.e. "load the chat tail, then scroll up". Keep this flag stable across one pagination sequence -- after is the same opaque cursor in both modes, only the direction of travel flips

Response 200:

{
  "items": [
    {
      "key": "0x...",           // Hex-encoded RocksDB key (for pagination)
      "msg_cbor": "0x..."       // Hex-encoded CBOR MsgV1
    }
  ],
  "next_after": "0x..."         // null when the page was not full (no continuation cursor)
}

Notes:

  • chat_id is derived from (user, peer) -- no membership check needed for DMs
  • Client must decode CBOR msg_cbor to get message fields (sender, text, hlc, origin_wall_ts, seq, msg_type, control, kind). origin_wall_ts is the frozen sender wall-clock for UI display; hlc is the network-consistent stamp used for storage ordering and sync. Two optional fields signal a client-side edit or delete: edited_at (packed HLC, present once the message has been edited) and deleted (true on a deleted stub, whose text is empty) -- both absent on untouched messages; see PATCH/DELETE /messages/{msg_id}
  • Inside msg_cbor, kind.t is a CBOR text string ("0" dm / "1" group / "2" channel, not an integer), and for DMs kind.d.peer is the original recipient fixed by the sender -- in an incoming message it equals the reader's own address. Interlocutor = d.peer == me ? sender : d.peer. This differs from /conversations, whose kind.peer is viewer-relative. See the decoding reference in CLIENT_GUIDE.md
  • next_after contract: the cursor is emitted only when the page hit limit; a shorter or empty page returns next_after: null, which means "end of this node's current view", not "end of history". Writes are async (200 before commit) and nodes converge via sync, so an empty page with a null cursor is ambiguous -- keep the after you used and retry later instead of concluding the history is exhausted. The cursor is the storage key of the page's last row, so any item's key is a valid after value (tail-polling can resume from the newest item received). A non-null cursor only means "the page was full" -- the next call may return an empty page
  • Forward (reverse=false) returns messages oldest-first (ascending HLC, which is monotonic across sends); reverse (reverse=true) returns them newest-first, with next_after pointing at the oldest row of the page so the next call walks further back
  • Under full replication the queried node serves this from its local store and responds immediately -- there is no 30 s network wait (that path only applies to the disabled sharded mode). A node that has not finished syncing returns its partial local view, and there is no completeness signal, so treat history as eventually consistent
  • skip_membership_check = true for DM routes

POST /dialogs/{peer}/messages/read

Mark messages as read up to a given sequence number.

Path params:

  • peer -- peer's address (hex 0x-prefixed)

Request body:

{
  "seq": 123                    // Sequence number (>= 1)
}

Response 200: empty body

Notes:

  • Marks all messages with seq <= given_seq as read
  • Broadcasts ReadProgress via gossip to update other nodes
  • Read progress is monotonic: only advances, never goes backward

DELETE /groups/{chat_id}/membership

Leave a group (self-remove).

Path params:

  • chat_id -- group chat identifier (hex 0x-prefixed, 32 bytes)

Request body:

{
  "sig": "0xabcdef..."            // ECDSA sig over keccak256(chat_id || sender || 1)
}

Response 200: empty body

Error 403: {"error": "owner cannot leave group"}

Notes:

  • The group owner cannot leave -- they must transfer ownership first (op_type: "transfer" via POST /groups/{chat_id}/ops). Admins and participants leave freely
  • Signature is required for gossip propagation verification
  • Inbox entry for the chat is cleared so the group disappears from /conversations
  • Publishes MembershipOp(Remove, target=self) via gossip for cross-node propagation; remote nodes receive the gossip and clear their local inbox copy for this user too

Group Messages

All group message endpoints require the caller to be a current member of the group (verified via is_member check in the MPSC handler).

POST /groups/{chat_id}/messages

Send a text message to a group.

Path params:

  • chat_id -- group chat identifier (hex 0x-prefixed, 32 bytes)

Request body:

{
  "text": "Hello group!"           // 1-45056 bytes of UTF-8 (44 KiB), byte-counted
}

Response 200:

{
  "chat_id": "0x...",
  "msg_id": "0x...",
  "ts": 1699900000000           // Originator wall-clock at send time
}

Notes:

  • text is the only transport for user content, encrypted or not (e.g. MLS application messages ride here with a client prefix); the 44 KiB cap counts bytes and fits a 32 KiB payload in base64 plus a prefix
  • The group title is fixed at creation (title in POST /groups/{chat_id}/ops, part of the chat_id derivation) and reported by /conversations and /members; it does not travel with individual messages
  • Membership check happens in put_message MPSC handler
  • Non-members receive error: "not a group member"
  • Inbox upsert for all members happens locally in process_db_op after message storage

POST /groups/{chat_id}/messages/control

Send a control message carrying a protocol / service structure to a group (attachments metadata, MLS Welcome/Commit). Not a transport for user content -- encrypted group messages ride text on the plain endpoint.

Path params:

  • chat_id -- group chat identifier (hex 0x-prefixed, 32 bytes)

Request body:

{
  "msg_type": 20,
  "control": "pGplbmNyeXB0aW9u"    // base64-encoded CBOR; max 32 KiB (<= 43692 base64 chars)
}

Response 200:

{
  "chat_id": "0x...",
  "msg_id": "0x...",
  "ts": 1699900000000           // Originator wall-clock at send time
}

GET /groups/{chat_id}/messages

Get group message history (paginated).

Path params:

  • chat_id -- group chat identifier (hex 0x-prefixed, 32 bytes)

Query params:

  • from -- start timestamp in ms (default 0)
  • to -- end timestamp in ms (optional)
  • after -- pagination cursor (hex-encoded RocksDB key)
  • limit -- max results (1-1000, default 100)
  • reverse -- scan direction (default false). Same semantics as the DM endpoint: false = oldest-first from from, true = newest-first from to (chat tail, then scroll up)

Response 200:

{
  "items": [
    {
      "key": "0x...",
      "msg_cbor": "0x..."
    }
  ],
  "next_after": "0x..."
}

Notes:

  • skip_membership_check = false -- explicit membership verification
  • Non-members receive empty result (not an error)
  • Same next_after contract and msg_cbor decoding notes as GET /dialogs/{peer}/messages above

POST /groups/{chat_id}/messages/read

Mark group messages as read up to a given sequence number.

Path params:

  • chat_id -- group chat identifier (hex 0x-prefixed, 32 bytes)

Request body:

{
  "seq": 123
}

Response 200: empty body

Notes:

  • skip_membership_check = false -- non-members receive error
  • Broadcasts ReadProgress via gossip

GET /groups/{chat_id}/members

List group members with roles plus the group's creation title. Pure local read from members CF + chats_meta -- no gossip roundtrip needed.

Path params:

  • chat_id -- group chat identifier (hex 0x-prefixed, 32 bytes)

Response 200:

{
  "members": [
    {
      "address": "0x1234...",
      "role": 1
    },
    {
      "address": "0x5678...",
      "role": 0
    }
  ],
  "title": "My Group Chat"
}

Notes:

  • Only current members can view the list
  • Role values: 0 = participant, 1 = admin, 2 = owner (exactly one per group)
  • title is the group's immutable creation title; null for unnamed groups (and for groups whose creation meta has not reached this node yet)
  • Non-members receive error: "not a group member"

POST /groups/{chat_id}/ops

Compound membership operation: create group, add/remove members with optional accompanying messages (MLS Welcome/Commit). This is the only group management endpoint.

Path params:

  • chat_id -- group chat identifier (hex 0x-prefixed, 32 bytes)

Request body:

{
  "ops": [
    {
      "target": "0x1234...5678",
      "sig": "0xabcdef...",
      "role": 0,
      "op_type": "add"
    }
  ],
  "messages": [
    {
      "text": "",
      "msg_type": 20,
      "control": "pGplbmNyeXB0aW9u",
      "recipients": ["0x1234...5678"]
    }
  ],
  "nonce": "0xabcdef1234567890abcdef1234567890",
  "title": "My Group Chat"
}

Response 200:

{
  "ops_processed": 1,
  "messages_sent": 1
}

Authorization:

  • ECDSA signature recovery per operation (API-level crypto check)
  • For create ops: nonce field is required (exactly 16 bytes); API verifies chat_id == blake3(domain || signer || nonce || title) where title defaults to the empty string when omitted
  • Role-based check in MPSC handler (DB access):
    • create: no prior state needed; signer becomes the owner (role=2)
    • add: signer must be admin or owner. Assigning role: 1 (admin) or changing an existing member's role requires the owner; the owner's own role cannot be modified via add; role: 2 is never assignable via add
    • remove: signer must be admin/owner, OR signer == target for self-remove. The owner cannot be removed (by anyone, including self) -- transfer ownership first
    • transfer: signer must be the owner; target (the new owner) must be an active member; self-transfer is rejected. Old owner becomes admin, target becomes owner
    • delete: signer must be the owner. Deletes the whole group

Behavior:

  • Membership ops processed first, then accompanying messages
  • Inbox entries (including for creator on Create) are updated locally by process_db_op after accompanying message storage
  • Duplicate Create rejected if group already has members
  • Batch ops published as one MembershipOpBatch gossip message
  • Within a batch, later ops see the effect of earlier ones (virtual overlay), so [create, add, add] or [create, transfer, remove(self)] work in a single request
  • After a delete op, any further op for the same chat in the same batch is rejected
  • delete tombstones all members and clears every member's inbox entry; stored messages are untouched (retention GC removes them later)
  • Each op independently verifiable via its own ECDSA signature
  • No atomicity guarantee between ops and messages
  • ops array must not be empty; messages, nonce and title are optional (nonce becomes required when the batch contains a create op)
  • Accompanying messages obey the same size caps as the dedicated message endpoints: text max 45056 bytes, control max 43692 base64 chars (32 KiB decoded); control is base64, like on the control endpoints
  • title sets the group's name at creation. It participates in the chat_id derivation (blake3(domain || creator || nonce || title)), so the exact same string must be used when computing the chat_id client-side. Immutable after creation. Constraints: 1..=128 bytes UTF-8, no control characters, only valid alongside a create op. Omit for an unnamed group
  • The stored title is returned in GET /conversations (inside kind) and GET /groups/{chat_id}/members
  • op_type values: "add", "remove", "create", "transfer", "delete"
  • role values: 0 = participant (default), 1 = admin (2 = owner is set only by create/transfer, never accepted in the request)

Error responses for /groups/{chat_id}/ops:

StatusCondition
400Invalid op_type, missing required fields, bad nonce length, chat_id binding mismatch (Create), invalid title (empty / >128 bytes / control chars / no create op in batch), accompanying message over the size caps (text 45056 bytes / control 43692 base64 chars)
403Insufficient role for the op (see Authorization above), target is the owner, transfer to non-member or self
409Group already exists (duplicate Create -- list_members returns non-empty)
422Signature verification failed

GET /messages/{msg_id}

Fetch a single message by its msg_id. Enables deep-linking straight to one message (e.g. from a push notification) without pulling a whole range.

Path params:

  • msg_id -- message identifier (hex 0x-prefixed, 32 bytes)

Response 200:

{
  "msg_id": "0x...",            // Echoed back (hex, 32 bytes)
  "msg_cbor": "0x..."           // Hex-encoded CBOR MsgV1 (same shape as range items)
}

Notes:

  • Pure local read -- under full replication every node holds all messages, so there is no gossip roundtrip
  • Authorization is enforced node-side from the decoded message: for DMs the caller must be one of the two participants (sender or peer); for groups/channels the caller must be a current member
  • A 404 is returned both when the id is unknown and when the caller may not see the message, so message existence is never leaked to non-participants
  • The msg_cbor payload is byte-identical to a range item's msg_cbor, so clients reuse their existing decode path
  • A message that was edited or deleted carries the optional edited_at / deleted fields inside the decoded msg_cbor (see the decode note under GET /dialogs/{peer}/messages) -- this is how a client detects the edit/delete state. Edits and deletes are issued via PATCH /messages/{msg_id} and DELETE /messages/{msg_id}

Error responses:

StatusCondition
400Malformed msg_id hex
404Unknown id, or caller not authorized to read it

PATCH /messages/{msg_id}

Edit one of your own messages. text is replaced in place -- the message keeps the same msg_id and the same position/seq in history, so nothing reorders and pagination cursors stay valid.

Path params:

  • msg_id -- message identifier (hex 0x-prefixed, 32 bytes)

Request body:

{
  "text": "corrected text"      // new content, 1-45056 bytes of UTF-8 (44 KiB), byte-counted (same cap as send endpoints)
}

Response 200:

{
  "msg_id": "0x...",            // 32 bytes hex, unchanged
  "op_ts": 1699900000000        // Server-stamped wall-clock component of the operation HLC; surface as "edited at"
}

No operation signature:

  • An edit carries no body sig. Authorship at the node rests on the request-level X-Sig auth headers -- the caller must be the message's original sender -- mirroring how a plain message send is authorized (also unsigned)
  • The new text is content whose authenticity each recipient verifies end-to-end on Layer 2: the ciphertext is a sign-then-encrypt envelope, checked exactly like a plain message, so no separate operation signature is needed
  • Contrast with DELETE /messages/{msg_id}, which does carry an operation sig: a delete leaves only an unauthenticatable deleted: true stub, so the node must authorize it explicitly. Edits are unsigned, deletes are signed

Notes:

  • Only the message's original author may edit it
  • 48-hour edit window: the edit is accepted only within 48 h of the original message, measured from HLC stamps so all nodes agree on the cutoff
  • Deletion is terminal -- once a message is deleted it can no longer be edited
  • On success a packed-HLC edited_at field appears on the message and surfaces in the decoded msg_cbor on GET endpoints (see GET /messages/{msg_id})
  • The edit propagates to online nodes via gossip and to offline nodes via anti-entropy sync; duplicate delivery is idempotent
  • Edits do not reorder the chat or change unread counts (the inbox is left untouched)

Error responses:

StatusCondition
400Malformed msg_id hex, or text outside 1..=45056 bytes
401Missing or invalid auth headers
403Not the author, edit window expired, or message already deleted
404Unknown msg_id
500Internal error

DELETE /messages/{msg_id}

Delete one of your own messages. The message becomes a stub: it stays in history at the same msg_id and position, but its text is cleared and a deleted: true flag is set, so pagination stays stable.

Path params:

  • msg_id -- message identifier (hex 0x-prefixed, 32 bytes)

Request body:

{
  "sig": "0x..."                // 65-byte ECDSA operation signature (see below), hex
}

Response 200:

{
  "msg_id": "0x...",            // 32 bytes hex
  "op_ts": 1699900000000        // Server-stamped wall-clock component of the operation HLC
}

Operation signature:

  • Unlike an edit, a delete requires a body sig -- an operation signature separate from the auth headers. X-Sig authenticates the HTTP request; sig authorizes the delete, must recover to the original message's sender address, and every node re-verifies it on receipt
  • The client signs keccak256(canonical_payload) where canonical_payload = chat_id[32] || target_msg_id[32] || op_kind[1] || blake3(new_text)[32] (97 bytes). op_kind = 1 for delete, and since there is no new text blake3("") (BLAKE3 of the empty string) fills the last 32 bytes
  • Standard Ethereum ECDSA recovery (r[32] || s[32] || v[1], v in {27,28} or {0,1})
  • chat_id is not in the URL -- the node looks it up from the stored message, but the client must feed it into the payload (computed for DMs, path param for groups)
  • The HLC / timestamp is not signed -- the server stamps it, mirroring membership ops

Why a delete is signed but an edit is not:

  • A delete erases content, leaving only a deleted: true stub. No recipient can authenticate that stub end-to-end -- they cannot tell "the author deleted this" from "a node erased it without permission." So the node itself must authorize the delete against this signature, and every node re-verifies it
  • An edit writes new content instead, whose authenticity the recipient checks end-to-end on Layer 2 (a sign-then-encrypt envelope, like any plain message), so it needs only the request-level X-Sig auth and no operation sig. Hence the asymmetry: edits are unsigned, deletes are signed

Notes:

  • Only the message's original author may delete it, and the same 48-hour window (measured from HLC stamps) applies
  • Deletion is terminal -- a deleted message cannot be revived or re-edited
  • The stub keeps the same msg_id and stays in GET responses, so pagination stays stable; clients should render "message deleted" and evict any cached text. The deleted: true flag surfaces in the decoded msg_cbor (see GET /messages/{msg_id})
  • Propagates to online nodes via gossip and to offline nodes via anti-entropy sync; duplicate delivery is idempotent
  • Deletes do not reorder the chat or change unread counts (the inbox is left untouched)

Error responses:

StatusCondition
400Malformed msg_id / sig hex
401Missing or invalid auth headers
403Not the author, edit window expired, or bad operation signature
404Unknown msg_id
500Internal error

PUT /identity

Store caller's opaque identity blob. Overwrites any previous value by HLC last-write-wins (no client-visible versioning). The identity is propagated network-wide: the write is gossiped to online peers and reconciled across all nodes by Merkle-tree anti-entropy sync (domain Identity). See PROTOCOL.md (PutIdentity) and SYNC.md.

Request body:

{
  "identity": "SGVsbG8gV29ybGQ="   // base64-encoded blob, max 1024 bytes raw
}

Response 200:

{}

Error responses:

StatusCondition
400Invalid base64, blob exceeds 1024 bytes
401Missing or invalid ECDSA signature

Notes:

  • The blob is opaque to the node -- it does not parse or validate the contents
  • Key in RocksDB identity CF = caller's 20-byte address
  • Routed through DbOp::PutIdentity (async DB writer: HLC last-write-wins, sync-index update, Merkle notify), then published as a PutIdentity gossip message

GET /identity/{address}

Retrieve a previously stored identity blob by user address. Any authenticated user can read any identity.

Path params:

  • address -- target user address (hex 0x-prefixed, 20 bytes)

Response 200:

{
  "identity": "SGVsbG8gV29ybGQ="   // base64-encoded blob
}

Response 404: No identity stored for this address.

Notes:

  • Direct synchronous read from identity CF
  • No membership or ownership check -- any authenticated caller can query any address

Discovery Endpoints (no authentication)

Two public endpoints let clients discover the network without hardcoding node URLs. Neither requires signature headers. See Building a Client for the bootstrap/failover flow.

GET /node/info

Returns the answering node's identity.

Response 200:

{
  "peer_id": "16Uiu2HAm4TfDHnyCsHwyCTDDbAV3g4Cc32DhSXoPzhbfLGN6HpQP",
  "api_url": "https://node1.example.com:8080"
}

Notes:

  • peer_id -- this node's libp2p PeerId (base58). Clients need it for the X-Node header when signing requests to this node
  • api_url -- the node's advertised API base URL (public_api_url in its config), null if the node does not advertise one
  • Served directly from in-process state; no node roundtrip

GET /network/nodes

Returns the catalog of live API-serving nodes known to the answering node, including itself when it advertises a public_api_url.

Response 200:

{
  "nodes": [
    {
      "peer_id": "16Uiu2HAm4TfDHnyCsHwyCTDDbAV3g4Cc32DhSXoPzhbfLGN6HpQP",
      "api_url": "https://node1.example.com:8080"
    },
    {
      "peer_id": "16Uiu2HAmKQKBgQD3g4Cc32DhSXoPzhbfLGN6HpQP4TfDHnyCsHwy",
      "api_url": "https://node2.example.com:8080"
    }
  ]
}

Notes:

  • Entries come from the in-memory API registry populated by libp2p identify announcements of currently connected peers (see PROTOCOL.md, "Identify-Based API Discovery") -- every listed endpoint was live at response time
  • Nodes that do not set public_api_url (relay-only or private nodes) never appear
  • The list is a point-in-time snapshot of the answering node's direct connections, not a global network census; querying any node yields enough live endpoints for failover
  • Order is not significant

Error Responses

Two body shapes exist; clients must handle both.

Flat error (any status): returned for everything except declared field validation -- authorization failures, not-found, malformed path parameters (bad hex, wrong id length), malformed cursors, and semantic input errors (unknown op_type, nonce required for create op, identity blob exceeds 1024 bytes). No fields map:

{
  "error": "forbidden"
}

Validation error (400 only): returned when a declared size/range rule on a body or query field fails (text 1-45056 bytes, limit 1-1000, seq >= 1, control payload size, ...). error is always the literal "validation_error" and fields names each offending field:

{
  "error": "validation_error",
  "fields": {
    "text": {
      "msg": "text must be 1..=45056 bytes of UTF-8 (44 KiB)",
      "value": ""
    }
  }
}

Dispatch rule: fields is present if and only if error equals "validation_error". A 400 alone does not imply a fields map.

Internal Architecture

HTTP Request
  |
  v
HTTP Metrics Middleware (optional, if expose_metrics=true)
  |
  v
CORS Layer (allow all origins/methods/headers)
  |
  v
Signature Middleware (auth.rs)
  |
  v
Route Handler
  |
  v
Command::* --> MPSC channel (4096 buffer) --> Event Loop
  |
  v
oneshot channel <-- Response from handler
  |
  v
HTTP Response

Each handler creates a Command variant, sends it via MPSC, and waits on a oneshot channel for the response.