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) 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
  • 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)

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:

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 []), 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-1000 Unicode scalar values (chars), not bytes
}

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)

POST /dialogs/{peer}/messages/control

Send an E2EE control message (handshake, key rotation, etc.).

Path params:

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

Request body:

{
  "msg_type": 1,                // 1-255 (0 is reserved for regular text)
  "control": "pGplbmNyeXB0aW9u"  // base64-encoded CBOR payload; max 1024 bytes (<= 1368 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)

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...",
      "last_text_preview": "Hey, how are you?",
      "unread": 3,
      "cursor": "0x..."
    }
  ],
  "next_after": "0x..."         // null if no more pages
}

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
  • unread = last_seq - last_read_seq (from user_read_progress CF)

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)

Response 200:

{
  "items": [
    {
      "key": "0x...",           // Hex-encoded RocksDB key (for pagination)
      "msg_cbor": "0x..."       // Hex-encoded CBOR MsgV1
    }
  ],
  "next_after": "0x..."         // null if no more pages
}

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
  • Messages are in chronological order (ascending HLC, which is monotonic across sends)
  • 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": "admin cannot leave group"}

Notes:

  • Admin cannot leave their own group -- must transfer ownership first
  • 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-1000 Unicode scalar values (chars), not bytes
}

Response 200:

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

Notes:

  • Group messages carry ChatKind::Group { title: None }. The protocol does not currently set or propagate a group title/avatar (the Create op has no title field), so /conversations reports title: null for groups -- group metadata management is not yet implemented
  • 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 an E2EE control message to a group (handshake, key rotation, etc.).

Path params:

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

Request body:

{
  "msg_type": 1,
  "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)

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)

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. Pure local read from members CF -- 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
    }
  ]
}

Notes:

  • Only current members can view the list
  • Role values: 0 = participant, 1 = admin
  • 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": 5,
      "control": "0xabcdef...",
      "recipients": ["0x1234...5678"]
    }
  ],
  "nonce": "0xabcdef1234567890abcdef1234567890"
}

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; API verifies chat_id == blake3(domain || signer || nonce)
  • Role-based check in MPSC handler (DB access):
    • create: signer becomes admin, no prior state needed
    • add: signer must be admin
    • remove: signer must be admin OR signer == target for self-remove

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
  • Each op independently verifiable via its own ECDSA signature
  • No atomicity guarantee between ops and messages
  • ops array must not be empty; messages and nonce are optional
  • op_type values: "add", "remove", "create"
  • role values: 0 = participant (default), 1 = admin

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

StatusCondition
400Invalid op_type, missing required fields, nonce mismatch (Create)
403Sender is not admin (for Add/Remove), sender is admin trying self-remove
409Group already exists (duplicate Create -- list_members returns non-empty)
422Signature verification failed

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/

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

Error Responses

Standard error:

{
  "error": "forbidden"
}

Validation error (400):

{
  "error": "validation_error",
  "fields": {
    "text": {
      "msg": "length must be between 1 and 1000",
      "value": "",
      "min": 1,
      "max": 1000
    }
  }
}

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.