Skip to content

Hive P2P Relay ​

Hive is Cortexa's standalone relay server that bridges multiple desktop clients for real-time thought sharing. It runs as a separate binary (cortexa-hive), typically deployed on a Raspberry Pi, small VM, or any always-on Linux box.

Project Spaces & Access Control ​

As of protocol version 2, Hive is no longer a flat "everyone sees everything" relay. Visibility is enforced server-side through project spaces and per-peer roles.

Scope & project_key ​

Every share now carries two routing fields:

FieldValuesMeaning
scope"everyone" | "project"everyone = visible to the whole hive (legacy broadcast). project = visible only to peers granted on the share's project_key.
project_keya project slugStable identifier for the project space. Distinct from project, the human-readable display name. Because it is a slug, two peers sharing the same project must name the project identically so their slugs match the relay's grant keys.

The relay enforces visibility on every read path: live fanout, the offline outbox, connect backfill, RequestManifest, RequestItems, the /poll HTTP endpoint, and /files/:id. A peer only ever receives or can pull shares that are everyone-scope, its own, or in a project space it has been granted. Reads use COALESCE(scope,'everyone'), so any legacy row with a NULL scope is treated as everyone (nothing already shared is retroactively hidden).

Roles & grants ​

Membership uses a privilege ladder: viewer < contributor < admin (constants ROLE_VIEWER / ROLE_CONTRIBUTOR / ROLE_ADMIN in hive-protocol; unknown/empty roles rank as viewer).

ActionRequired privilege
Share into a project spacecontributor+ on that project
Share everyone-scope (whole hive)contributor+ hive-wide
Share into any project spacehive-wide admin
Receive sharesviewer+ on the relevant scope/project

Two new SQLite tables back this: projects (the spaces) and grants (per-peer, per-project role). A peer's hive-wide default role lives in peers.role.

Scoped invites ​

An invite can grant specific project keys plus a role:

  • A project-scoped invite (one or more projects) makes the peer a viewer hive-wide but holds their invite role on those specific projects. This is how "I connect to a teammate's hive with limited access" works — the peer can contribute to the projects they were invited to, but cannot broadcast to the whole hive or see other projects.
  • An unscoped invite (no projects) sets the role hive-wide.

Personal hive vs team hive ​

  • Personal hive — you sync your own machines. Each connection shares everything everyone-scope (full sync). Simplest model.
  • Team hive — members are scoped by project. Connections route only the chosen project thoughts into their matching project spaces, and scoped invites limit each teammate to the projects they belong to.

See the user-facing Hive Sharing & Teams guide for how this is configured in the desktop app.

Architecture ​

┌──────────────┐     WebSocket      ┌──────────────┐
│  Cortexa     │◄──────────────────►│              │
│  Desktop A   │                    │              │
└──────────────┘                    │    Hive      │
                                    │    Server    │
┌──────────────┐     WebSocket      │              │
│  Cortexa     │◄──────────────────►│              │
│  Desktop B   │                    │              │
└──────────────┘                    │  ┌────────┐  │
                                    │  │ SQLite  │  │
┌──────────────┐     WebSocket      │  │ Store   │  │
│  Cortexa     │◄──────────────────►│  └────────┘  │
│  Desktop C   │                    │              │
└──────────────┘                    │  ┌────────┐  │
                                    │  │  /pub/  │  │
         Browser ──── HTTP GET ────►│  │  HTML   │  │
                                    │  └────────┘  │
                                    └──────────────┘

Crate Structure ​

The Hive server is split across two crates:

CratePathPurpose
hive-protocolhive-protocol/src/lib.rsShared wire-format types (messages, requests, responses) used by both server and client
cortexa-hivehive/src/Server binary with routes, relay logic, storage, and publishing

Server Modules ​

ModuleFilePurpose
main.rsCLI entry, server startup, tracing init, GC tasks
config.rshive.toml config management, admin token generation
store.rsSQLite: peers, invites, shared items, publications, files, projects, grants; at-rest content cipher for shared-item text
server.rsAxum routes, WebSocket handler, admin endpoints, file transfer
relay.rsClient registry, message broadcast, scope-aware fanout + authorization, scope-filtered backfill
publish.rsStatic HTML blog generation (index + individual posts)
crypto.rsAES-256-GCM encryption at rest — file blobs and shared-item content/preview/summary/meta

WebSocket Protocol ​

Clients communicate with the Hive server over a persistent WebSocket connection at GET /ws. All messages are JSON-serialized HiveMessage enum variants.

Connection Flow ​

Client                                  Server
  │                                       │
  │──── GET /ws  (signed headers) ───────►│
  │                                       │ Verify signature / token
  │◄──── Authenticated { client_id,       │
  │       peers, protocol_version,        │
  │       role, projects }                │
  │                                       │
  │◄──── Backfill { items: [...] } ──────│ (last 7 days, scope-filtered)
  │                                       │
  │◄──── PeerUpdate { connected } ───────│ (when others join/leave)
  │                                       │
  │──── ShareItem { ... } ──────────────►│
  │                                       │──── ItemShared ────► Other peers
  │                                       │
  │◄──── ItemShared { ... } ─────────────│ (from another peer)
  │                                       │
  │──── DeleteItem { item_id } ─────────►│
  │                                       │──── ItemDeleted ───► Other peers
  │                                       │
  │──── Ping ───────────────────────────►│ (keepalive)
  │                                       │

Authentication ​

Authentication can happen these ways:

  1. Signed upgrade (preferred): a peer with a registered Ed25519 key sends x-cortexa-key (its client id), x-cortexa-timestamp, x-cortexa-nonce and Authorization: Signature <b64> on the upgrade request, and no token crosses the wire. The same headers authenticate the peer's HTTP requests (/files/:id, /published/:id, /project, /poll, /peers).
  2. Query parameter (deprecated): GET /ws?token=<token>. Only clients older than v0.11.0 use it; the token ends up in proxy access logs, so the relay logs a deprecation warning each time. No other route reads a token from the query string.
  3. First message: If no query token is provided, the server waits up to 10 seconds for an Authenticate { hive_token } message.

Signatures have two formats. v2 signs cortexa-hive-auth-v2, client id, timestamp, nonce, the HTTP method and the route path (hive_protocol::auth_challenge_v2; the path excludes the query string and any proxy prefix), so a signature is good for one request only. v1 (auth_challenge) signs only client id, timestamp and nonce; a captured v1 signature could be spent on a different endpoint within the ±60 s window, once. v1 is deprecated: the relay still accepts it from older clients (v0.43.1 and earlier) and logs a warning per peer at most hourly. Current clients read signature_v2 from GET /health and sign v2 when the relay supports it, or v1 for relays that predate it. Both formats use single-use nonces.

If authentication fails, the server sends AuthFailed { reason } and closes the connection.

Message Types ​

MessageDirectionDescription
AuthenticateClient -> ServerSend bearer token (for unsigned connections). Carries optional protocol_version.
AuthenticatedServer -> ClientConfirm auth. Includes the full peer list plus the relay's protocol_version, this peer's hive-wide role, and its granted projects (Vec<ProjectGrant>).
AuthFailedServer -> ClientAuth rejected with reason
ShareItemClient -> ServerShare a thought (content, preview, summary, tags, meta). Carries optional project, project_key, and scope for project-space routing.
ItemSharedServer -> ClientsBroadcast a newly shared item to peers that may see it. Echoes project/project_key/scope.
ItemUpdatedServer -> ClientsBroadcast an update to an existing shared item. Echoes project/project_key/scope.
DeleteItemClient -> ServerRequest deletion of a shared item
ItemDeletedServer -> ClientsBroadcast deletion to all peers
PeerUpdateServer -> ClientsNotify about peer connect/disconnect (peer carries optional role)
BackfillServer -> ClientBatch of missed items on reconnect; each BackfillItem carries project/project_key/scope
PingClient -> ServerKeepalive (no response)
Unknown—Forward-compat catch-all (#[serde(other)]). Any message whose type this build doesn't recognize deserializes here instead of erroring the socket. See Protocol Versioning.

The scope/project_key fields are all additive Options with serde defaults — a v1 client sends none, and the relay infers everyone scope when project_key is absent.

Protocol Versioning ​

hive-protocol exposes PROTOCOL_VERSION (currently 2). It is sent on both Authenticate and Authenticated so the two ends can detect a mismatch. The contract for compatibility:

  • Additive optional fields don't bump the version. New Option fields with serde defaults (the #[serde(default, skip_serializing_if = ...)] pattern) are tolerated by older peers, so they don't require a bump. v2 itself added scope, project_key, role, and projects this way.
  • Unknown is a forward-compat fallback. A #[serde(other)] catch-all variant (added in v2) means a peer that predates a future HiveMessage variant deserializes it as Unknown and ignores it, rather than dropping the whole socket on an unknown type.
  • Old client → new relay: sends no scope, so shares default to everyone (requires contributor+ hive-wide). Fine for existing full members.
  • New client → old relay: the old relay ignores the unknown scope/project_key fields and behaves as before (no enforcement) until it is upgraded.

Structured Data Transfer ​

Shared items carry a ThoughtMeta payload in the meta field that includes:

  • Thesis output (expansion, counterpoints, structured brief)
  • Summary text
  • Notes / conversation thread
  • File references (FileRef with id, filename, mime_type, size, sha256)
  • Briefing script and audio reference
  • Tags

Receiving clients store this metadata in their proper database tables (theses, notes, tags, attachments) so imported items render identically to locally-created ones. File attachments are downloaded lazily via authenticated HTTP on import.

Binary File Transfer ​

Files (audio briefings, images, PDFs) are transferred via HTTP sideband rather than inline in WebSocket messages:

EndpointAuthPurpose
POST /filesBearer (admin or peer)Upload a file (50MB limit). Returns FileRef with id, sha256.
GET /files/{id}Bearer (admin or peer)Download a file by ID.
GET /pub/files/{id}NoneDownload a file linked to a published share.

Files are AES-256-GCM encrypted at rest with a key derived from the admin token. SHA-256 integrity verification on upload and download. Server-side dedup by hash — uploading the same file twice returns the existing FileRef. Orphan files (not linked to any share) are garbage-collected every 6 hours.

The flow: client uploads files via HTTP first, includes FileRef metadata in the WebSocket ShareItem message, peers download files on demand during import.

Cross-User Thesis Synthesis ​

When a client imports a Hive item that contains thesis data (ThoughtMeta.thesis_json), the system can automatically synthesize cross-user perspectives:

  1. The import hook checks if the peer item has a thesis.
  2. If reflection.cross_synthesis is enabled, enqueues a CrossSynthesize job.
  3. The handler embeds the peer content and searches the local vault for similar items with theses.
  4. If a match is found, an LLM generates a synthesis: agreements, disagreements, novel combinations.
  5. The output is stored as a new type:cross-synthesis item with links to both source items.

Peer Registration ​

New clients register via POST /register with an invite code:

json
{
  "client_id": "unique-uuid",
  "display_name": "Zach's Laptop",
  "invite_code": "3f9a12bc-7d40e8a1-c25b9f06-4e1d8a73"
}

The server:

  1. Validates the invite code (checks existence, expiry, max uses).
  2. Records the use (increments uses counter).
  3. Registers the peer in the peers table, applying the invite's role and project grants (see Scoped invites).
  4. Returns a bearer token plus the granted role and project spaces.
json
{
  "hive_token": "64-char-hex-string",
  "peers": [...],
  "role": "contributor",
  "projects": [
    { "project_key": "atlas", "project_name": "Atlas", "role": "contributor" }
  ]
}

role is the peer's hive-wide default; projects is the list of ProjectGrants the invite granted. The token is stored by the desktop client and used for all future WebSocket connections and HTTP API calls. The desktop UI uses role/projects to reflect the access this hive granted (e.g. marking a viewer connection read-only).

Invite Code Management ​

Invite codes are managed through the admin API:

  • POST /admin/invite -- Create a new code with optional label, max uses, expiry, projects (project keys), and role. With no expires_at the code expires after 7 days; never_expires: true opts out. Admin-role codes are capped at 24 hours unless the request also sets allow_long_admin_expiry: true. Codes created before this default keep their stored (possibly null) expiry.
  • GET /admin/invites -- List all codes (each reports its projects and role).
  • DELETE /admin/invite/{code} -- Revoke a code.

Codes are 128 random bits generated server-side, shown once as four dash-separated groups of eight hex digits (3f9a12bc-...). Only a hash is stored. Dashes, whitespace and case are ignored on redemption. Codes issued before this format (8-character UUID prefixes) still redeem. Failed redemptions are capped hive-wide (10 per minute; further attempts get 429). An invite can have:

  • A human-readable label (e.g., "for-friends")
  • A max_uses limit (NULL = unlimited)
  • An expires_at timestamp (NULL = never expires)
  • A projects list of project keys to grant (empty = no project spaces, whole-hive everyone shares only)
  • A role (viewer | contributor | admin; absent defaults to viewer)

A project-scoped invite (non-empty projects) makes the peer a viewer hive-wide but holds the invite role on those projects. An unscoped invite sets the role hive-wide. See Scoped invites.

Backfill on Reconnect ​

When a client connects (or reconnects after being offline), the server sends one or more Backfill messages containing the shared items from the last 7 days that the connecting peer is allowed to see:

rust
let since = Utc::now() - chrono::TimeDelta::days(7);
let backfill_items = state.relay.get_backfill(since, &viewer);

get_backfill calls store::list_shared_since(since, viewer), which applies the same server-side scope filter as live fanout — a peer only backfills everyone-scope shares, its own, and shares in project spaces it is granted on. Results are batched into multiple Backfill frames bounded by both a count cap and a serialized-byte cap (kept under the client's inbound WebSocket message limit); the client applies repeated frames additively.

This ensures clients catch up on missed messages after disconnection without requiring a full resync, while never leaking shares outside their granted scopes.

Deduplication ​

When a client shares the same item a second time (same from_client_id + item_id), the relay detects the existing share and updates it instead of creating a duplicate:

rust
if let Some(existing_id) = self.store.find_share_by_origin(from_client_id, item_id)? {
    // Update existing share content
    self.store.update_shared_item_content(&existing_id, ...)?;
    // Broadcast ItemUpdated instead of ItemShared
    broadcast(HiveMessage::ItemUpdated { ... });
    return Ok(existing_id);
}

This handles the common case of re-sharing after a thesis completes or content is edited. Peers receive an ItemUpdated message and update their local feed accordingly.

Publication System ​

Shared items can be published as static HTML blog posts, accessible at the server's /pub/ URL without authentication.

Publishing Flow ​

Admin clicks Publish (or auto-publish triggers)
        │
        v
POST /admin/publish/{share_id}  { "title": "..." }
        │
        v
Generate slug from title (slugify, deduplicate)
        │
        v
Store publication record in SQLite
        │
        v
Generate individual post HTML (pub/{slug}.html)
        │
        v
Regenerate blog index HTML (pub/index.html)

HTML Generation ​

The publish.rs module generates self-contained HTML pages and an RSS/Atom feed:

  • Individual posts: Clean typography, responsive layout, one CSS stylesheet inlined per page. Includes title, author, date, content (rendered from markdown), summary, and conversation thread if present.
  • Blog index: Lists all published posts with titles, dates, and preview text. Links to individual post pages.
  • RSS feed: feed.xml with all published posts for feed reader subscriptions.

All file writes use atomic tmp+rename to prevent partial reads during updates.

Live Updates ​

When a published item is updated (content edited, reply added, thesis completed), the HTML page is automatically regenerated:

rust
// In update_shared handler:
if let Ok(Some(pub_info)) = state.store.get_publication_for_share(&share_id) {
    publish::generate_post(...)?;
    publish::generate_index_enriched(...)?;
}

Unpublish ​

Unpublishing removes the HTML file and regenerates the index. The share itself is preserved in the database.

Unshare ​

Unsharing deletes both the share record and any associated publication (HTML file + index regeneration).

Admin API ​

All admin endpoints require the admin bearer token (generated on first run, stored in hive.toml).

MethodPathPurpose
POST/admin/inviteGenerate an invite code (body accepts projects + role for scoped invites)
GET/admin/invitesList invite codes
DELETE/admin/invite/{code}Revoke an invite
GET/admin/sharedList all shared items (up to 200)
PUT/admin/shared/{share_id}Update a shared item's content
DELETE/admin/shared/{share_id}Delete a share (cascades to publication)
POST/admin/publish/{share_id}Publish as static HTML
DELETE/admin/publish/{share_id}Unpublish (removes HTML, keeps share)
GET/admin/publicationsList all publications
POST/admin/regenerateRegenerate the blog index page
GET/admin/projectsList project spaces with rollup counts (ProjectInfo)
GET/admin/grantsList all peer↔project grants (GrantInfo)
POST/admin/grantCreate/update a grant (GrantRequest: peer_client_id, project_key, role). Upserts the project if needed.
POST/admin/grant/revokeRevoke a grant (POST with body; role ignored)
GET/admin/peer/{client_id}Per-peer drill-down (PeerDetail: identity, role, activity rollup, current grants)
GET/admin/deploymentsList deployed Foundry build artifacts (DeploymentInfo)
DELETE/admin/deploy/{slug}Remove a deployment

Security Hardening ​

Constant-Time Authentication ​

Admin token comparison uses the subtle crate's ConstantTimeEq to prevent timing attacks:

rust
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() { return false; }
    a.ct_eq(b).into()
}

WebSocket Limits ​

The WebSocket upgrade applies frame and message size limits to prevent memory exhaustion:

rust
ws.max_frame_size(1024 * 1024)        // 1 MB per frame
  .max_message_size(4 * 1024 * 1024)  // 4 MB per message

Body Size Limits ​

The Axum router applies a global body limit:

rust
.layer(DefaultBodyLimit::max(8 * 1024 * 1024))  // 8 MB

Input Validation ​

Registration inputs are length-capped:

  • client_id: max 128 characters
  • display_name: max 128 characters
  • invite_code: max 64 characters

Empty client_id or display_name is rejected with 400 Bad Request.

Path Traversal Protection ​

Published HTML file paths are validated to prevent directory traversal:

rust
let html_path = pub_dir.join(format!("{slug}.html"));
if !html_path.starts_with(&pub_dir) {
    return Err((StatusCode::BAD_REQUEST, "invalid title"));
}

Token Generation ​

Both admin tokens and peer tokens are 32 random bytes (64 hex characters) generated via rand::thread_rng():

rust
pub fn generate_token() -> String {
    let mut bytes = [0u8; 32];
    rand::thread_rng().fill(&mut bytes);
    hex::encode(bytes)
}

Relay Implementation ​

The relay (relay.rs) manages connected clients through a RwLock<HashMap<String, ConnectedClient>>:

  • add_client: Registers a connected peer, notifies existing peers via PeerUpdate.
  • remove_client: Removes a disconnected peer, notifies remaining peers.
  • handle_share: Authorizes the sender's role for the share's scope/project, stores the (encrypted) item, and fans out only to peers permitted to see it.
  • handle_delete: Removes the share, broadcasts deletion to all other peers.
  • broadcast_all: Sends a message to every connected client (used by admin update/delete).

Fanout, the offline outbox, and backfill all apply the same server-side scope filter, so a share never reaches a peer outside its everyone/own/granted-project visibility.

Each connected client has an mpsc::UnboundedSender<HiveMessage> channel. The WebSocket handler spawns two tasks per connection: one forwarding relay messages to the socket, one reading client messages from the socket.

Data Storage ​

The Hive server uses its own SQLite database (hive.db) with tables for:

  • peers: Registered clients with client_id, display_name, token, last_seen timestamp, and role (hive-wide default).
  • invites: Invite codes with label, max_uses, uses count, expiry, and projects (JSON array of keys) + role.
  • shared_items: All shared content with from_client_id, item_id, preview, content, summary, tags, shared_at, and project, project_key, scope. The content, preview, summary, and meta columns are AES-256-GCM encrypted at rest (key derived from admin_token); routing/search columns (project, project_key, scope, tags) stay plaintext.
  • projects: Project spaces — key (slug, primary key), name, created_at.
  • grants: Per-peer, per-project access — peer_client_id, project_key, role, granted_at (PK on the peer+project pair).
  • publications: Published items with share_id, title, slug, and published_at.
  • deployments: Deployed Foundry build artifacts with slug, run_id, kit_id, title, mode, url, file_count, bytes.
  • files: Uploaded file metadata with id, sha256, encrypted_path, uploader_id, share_id.
  • delivery_receipts: Tracks which peers have received which shares.
  • peer_outbox: Messages queued for offline peers (30-day retention).
  • wire_events: Relay event log (metadata only) for the admin dashboard.

Content encryption at rest ​

The sensitive text columns of shared_items are encrypted by a content cipher (store.rs → crypto.rs) whose key derives from the relay's admin_token — the same trust root that already protects file blobs. Decryption is transparent on read; legacy plaintext rows are detected by the absence of the enc1: prefix and read back verbatim (lazy migration, no rewrite pass). Because the key comes from admin_token, rotating admin_token after upgrading makes previously-encrypted content and files unreadable — don't do it.

Migration ​

Schema migration runs automatically on startup and is additive and metadata-only: it issues ALTER TABLE ADD COLUMN and CREATE TABLE IF NOT EXISTS statements (instant regardless of DB size) and never back-fills scope (reads use COALESCE(scope,'everyone')). Existing peers are grandfathered to admin so the upgrade never bars them from sharing. The one large index — partial shared_items(project_key) — is built after the port binds, on a separate connection, so startup is never blocked.

Configuration ​

The server configuration lives in hive.toml:

toml
port = 7474
bind = "0.0.0.0"
site_title = "My Hive"
admin_token = "64-char-hex-string"
FieldDefaultDescription
port7474Listen port
bind"0.0.0.0"Bind address
site_title""Blog title (falls back to "Cortexa Hive")
admin_token(generated)Admin bearer token

On first run, if hive.toml does not exist, the server creates it with a freshly generated admin token and prints the token to stdout.

Client Integration ​

The desktop app's Hive client (src-tauri/src/hive/client.rs) manages the WebSocket connection with auto-reconnect. It handles:

  • Auto-share: When enabled, every new capture (from any source) is automatically shared after ingestion.
  • Auto-import: When enabled, received items are automatically imported into the local vault with hive:shared and hive:from:<name> tags.
  • Auto-publish: When enabled, shared items are automatically published as blog posts after the thesis pipeline completes.
  • Live updates: Thesis completions, replies, and edits trigger re-shares to update the Hive copy.
  • Peer profile cards: The Hive feed displays peer profile cards showing name, item count, and trust score.
  • Sync log viewer: The connection panel includes a sync log viewer for debugging Hive communication.

Built with Rust, Tauri, and a lot of captured thoughts.