Skip to content

Nodes and signalling

Each account is one hive. Engines (engine) and thin clients (thin) register an Ed25519 public key as a node, then find each other through the server and connect peer to peer. The server is a directory and signalling relay. It never stores thread content.

Note

coturn (a TURN relay) is not deployed. turn_enabled is off and the ICE configuration returns STUN servers only. The server-side encrypted relay over the signalling socket is the only fallback when a direct connection fails.

Identity and registration

All encodings are base64url without padding. A node id is n_ plus the lowercase base32 (no padding) of the first 16 bytes of SHA-256 over the public key bytes. The server recomputes it.

POST /nodes takes public_key, kind, name, platform, app_version, protocol_version (1), proof and an optional invite_code. The proof is an Ed25519 signature over sc-register-v1\n{user_id}\n{public_key}. Logging in to the account is the only link: every node is active as soon as it registers, and nodes only see nodes with the same account. invite_code is deprecated and ignored. Registering the same key again returns the existing node (a revoked node stays revoked). The pending status exists only for nodes pairing directly over the LAN, where the server is not involved; a legacy pending row becomes active when that node itself re-registers or authenticates.

REST endpoints

All endpoints need a bearer access token and are scoped to the caller's account. A node id from another account returns the same 404 as an unknown id.

  • GET /nodes lists hive_id and nodes.
  • POST /nodes/{id}/approve is deprecated and unnecessary; it returns the node and accepts the old body (approver_node_id, approval_sig) without checking the signature.
  • DELETE /nodes/{id} revokes a node and closes its sockets.
  • POST /nodes/invites is deprecated: it returns a code that is never needed.
  • POST /nodes/{id}/signal-ticket returns ticket, challenge and expires_in (30).
  • POST /nodes/{id}/ice-config returns ice_servers, ws_relay_enabled and ttl.
  • POST /nodes/{id}/pairing-assertion and POST /nodes/{id}/pairing-assertion/verify pair a browser extension with one of the caller's engines (see below).

Extension pairing

A browser extension that signed in to the account asks for a one-time assertion for an active engine node it owns, hands it to the local engine, and the engine verifies it with its own account session. The assertion is stored hashed in LMDB, bound to the user, node, client nonce and extension origin, and is never logged.

POST /nodes/{id}/pairing-assertion with {"client_nonce": "<16-128 chars>", "origin": "chrome-extension://<32 a-p>" | "moz-extension://<uuid>"} returns {"assertion": "...", "expires_in": 60}. The nonce may contain letters, digits and _-.~+/=. A node that is unknown, owned by another account or not an engine gives 404; a revoked node gives 403; a malformed body gives 422. Audit event: pairing_issued.

POST /nodes/{id}/pairing-assertion/verify with {"assertion", "client_nonce", "origin"} and the same account's bearer returns {"ok": true, "user_id": "..."}. The assertion is consumed by the first attempt, so a replay, a wrong nonce, origin, node or user, an expired assertion or a revoked node all return 404 {"detail": "Invalid assertion"}. Audit event: pairing_verified.

Both endpoints share the RATE_LIMIT_PAIRING limit (default 10/minute). The assertion lifetime is PAIRING_ASSERTION_TTL_SECONDS (default 60, 5 to 300).

Signing in from an extension

Send POST /auth/email/login?native=true with JSON {"email", "password"}. The response body holds access_token, expires_in, refresh_token and user; without native=true the refresh token goes into an httponly cookie instead. Renew with POST /auth/refresh and JSON {"refresh_token": "..."}: the body form is accepted without cookies and returns a rotated refresh_token, so store the new one each time. No Origin or CSRF check applies to these endpoints. Both use the RATE_LIMIT_AUTH limit.

Signalling WebSocket

Connect to /signal. Frames are JSON {"v":1,"type":...,"data":{...}}, at most 64 KiB. The first frame must be auth within 5 seconds, with ticket, node_id and sig, an Ed25519 signature over sc-signal-v1\n{challenge}\n{sha256_hex(ticket)}\n{node_id}. Failure sends error with auth_failed and closes with code 4401.

Message types: presence.set, presence.update, presence.snapshot, signal, relay, auth.refresh, ping and pong. Error codes are auth_failed, unknown_node, rate_limited, too_large, bad_request and relay_disabled. The socket closes with 4401 when the session expires unless auth.refresh supplies a new ticket.

Limits

Each connection has a token bucket (30 messages per 10 seconds by default), at most 5 sockets per node, 20 nodes per account and a 60 second idle timeout.