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:
| Field | Values | Meaning |
|---|---|---|
scope | "everyone" | "project" | everyone = visible to the whole hive (legacy broadcast). project = visible only to peers granted on the share's project_key. |
project_key | a project slug | Stable 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).
| Action | Required privilege |
|---|---|
| Share into a project space | contributor+ on that project |
Share everyone-scope (whole hive) | contributor+ hive-wide |
| Share into any project space | hive-wide admin |
| Receive shares | viewer+ 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 aviewerhive-wide but holds their inviteroleon 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 therolehive-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:
| Crate | Path | Purpose |
|---|---|---|
hive-protocol | hive-protocol/src/lib.rs | Shared wire-format types (messages, requests, responses) used by both server and client |
cortexa-hive | hive/src/ | Server binary with routes, relay logic, storage, and publishing |
Server Modules
| Module | File | Purpose |
|---|---|---|
main.rs | CLI entry, server startup, tracing init, GC tasks | |
config.rs | hive.toml config management, admin token generation | |
store.rs | SQLite: peers, invites, shared items, publications, files, projects, grants; at-rest content cipher for shared-item text | |
server.rs | Axum routes, WebSocket handler, admin endpoints, file transfer | |
relay.rs | Client registry, message broadcast, scope-aware fanout + authorization, scope-filtered backfill | |
publish.rs | Static HTML blog generation (index + individual posts) | |
crypto.rs | AES-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:
- Signed upgrade (preferred): a peer with a registered Ed25519 key sends
x-cortexa-key(its client id),x-cortexa-timestamp,x-cortexa-nonceandAuthorization: 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). - 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. - 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
| Message | Direction | Description |
|---|---|---|
Authenticate | Client -> Server | Send bearer token (for unsigned connections). Carries optional protocol_version. |
Authenticated | Server -> Client | Confirm auth. Includes the full peer list plus the relay's protocol_version, this peer's hive-wide role, and its granted projects (Vec<ProjectGrant>). |
AuthFailed | Server -> Client | Auth rejected with reason |
ShareItem | Client -> Server | Share a thought (content, preview, summary, tags, meta). Carries optional project, project_key, and scope for project-space routing. |
ItemShared | Server -> Clients | Broadcast a newly shared item to peers that may see it. Echoes project/project_key/scope. |
ItemUpdated | Server -> Clients | Broadcast an update to an existing shared item. Echoes project/project_key/scope. |
DeleteItem | Client -> Server | Request deletion of a shared item |
ItemDeleted | Server -> Clients | Broadcast deletion to all peers |
PeerUpdate | Server -> Clients | Notify about peer connect/disconnect (peer carries optional role) |
Backfill | Server -> Client | Batch of missed items on reconnect; each BackfillItem carries project/project_key/scope |
Ping | Client -> Server | Keepalive (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
Optionfields with serde defaults (the#[serde(default, skip_serializing_if = ...)]pattern) are tolerated by older peers, so they don't require a bump. v2 itself addedscope,project_key,role, andprojectsthis way. Unknownis a forward-compat fallback. A#[serde(other)]catch-all variant (added in v2) means a peer that predates a futureHiveMessagevariant deserializes it asUnknownand ignores it, rather than dropping the whole socket on an unknowntype.- Old client → new relay: sends no
scope, so shares default toeveryone(requirescontributor+hive-wide). Fine for existing full members. - New client → old relay: the old relay ignores the unknown
scope/project_keyfields 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:
| Endpoint | Auth | Purpose |
|---|---|---|
POST /files | Bearer (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} | None | Download 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:
- The import hook checks if the peer item has a thesis.
- If
reflection.cross_synthesisis enabled, enqueues aCrossSynthesizejob. - The handler embeds the peer content and searches the local vault for similar items with theses.
- If a match is found, an LLM generates a synthesis: agreements, disagreements, novel combinations.
- The output is stored as a new
type:cross-synthesisitem with links to both source items.
Peer Registration
New clients register via POST /register with an invite code:
{
"client_id": "unique-uuid",
"display_name": "Zach's Laptop",
"invite_code": "3f9a12bc-7d40e8a1-c25b9f06-4e1d8a73"
}The server:
- Validates the invite code (checks existence, expiry, max uses).
- Records the use (increments
usescounter). - Registers the peer in the
peerstable, applying the invite's role and project grants (see Scoped invites). - Returns a bearer token plus the granted role and project spaces.
{
"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), androle. With noexpires_atthe code expires after 7 days;never_expires: trueopts out. Admin-role codes are capped at 24 hours unless the request also setsallow_long_admin_expiry: true. Codes created before this default keep their stored (possibly null) expiry.GET /admin/invites-- List all codes (each reports itsprojectsandrole).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_useslimit (NULL = unlimited) - An
expires_attimestamp (NULL = never expires) - A
projectslist of project keys to grant (empty = no project spaces, whole-hiveeveryoneshares only) - A
role(viewer|contributor|admin; absent defaults toviewer)
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:
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:
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.xmlwith 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:
// 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).
| Method | Path | Purpose |
|---|---|---|
POST | /admin/invite | Generate an invite code (body accepts projects + role for scoped invites) |
GET | /admin/invites | List invite codes |
DELETE | /admin/invite/{code} | Revoke an invite |
GET | /admin/shared | List 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/publications | List all publications |
POST | /admin/regenerate | Regenerate the blog index page |
GET | /admin/projects | List project spaces with rollup counts (ProjectInfo) |
GET | /admin/grants | List all peer↔project grants (GrantInfo) |
POST | /admin/grant | Create/update a grant (GrantRequest: peer_client_id, project_key, role). Upserts the project if needed. |
POST | /admin/grant/revoke | Revoke 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/deployments | List 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:
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:
ws.max_frame_size(1024 * 1024) // 1 MB per frame
.max_message_size(4 * 1024 * 1024) // 4 MB per messageBody Size Limits
The Axum router applies a global body limit:
.layer(DefaultBodyLimit::max(8 * 1024 * 1024)) // 8 MBInput Validation
Registration inputs are length-capped:
client_id: max 128 charactersdisplay_name: max 128 charactersinvite_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:
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():
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 viaPeerUpdate.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, androle(hive-wide default).invites: Invite codes with label, max_uses, uses count, expiry, andprojects(JSON array of keys) +role.shared_items: All shared content with from_client_id, item_id, preview, content, summary, tags, shared_at, andproject,project_key,scope. Thecontent,preview,summary, andmetacolumns are AES-256-GCM encrypted at rest (key derived fromadmin_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:
port = 7474
bind = "0.0.0.0"
site_title = "My Hive"
admin_token = "64-char-hex-string"| Field | Default | Description |
|---|---|---|
port | 7474 | Listen 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:sharedandhive: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.