A sovereign, decentralized real-time communication platform built on DIDs, Verifiable Credentials, ZCAP authorization, and end-to-end encryption.
Harmony runs across five deployment targets sharing a common UI and protocol layer.
graph TB
subgraph Clients
Electron["Electron Desktop App<br/>(BrowserWindow + ServerRuntime)"]
WebUI["Web UI<br/>(SPA via WebSocket)"]
Mobile["Mobile<br/>(Capacitor Android/iOS)"]
end
subgraph Self-Hosted
ServerRuntime["server-runtime<br/>(Node.js daemon)"]
SQLite[(SQLite QuadStore)]
ServerRuntime --- SQLite
end
subgraph Cloudflare
CloudWorker["Cloud Worker<br/>(Durable Objects)"]
CommunityDO["CommunityDurableObject<br/>(DO SQLite + Hibernatable WS)"]
CFSFU["CF Realtime SFU<br/>(Managed WebRTC)"]
CloudWorker --- CommunityDO
PortalWorker["Portal Worker"]
D1[(D1 Identity Store)]
R2[(R2 Export Store)]
KV[(KV Rate Limiter)]
PortalWorker --- D1
PortalWorker --- R2
PortalWorker --- KV
end
Electron -->|embeds| ServerRuntime
WebUI -->|WebSocket| ServerRuntime
WebUI -->|WebSocket| CloudWorker
Mobile -->|WebSocket| ServerRuntime
Mobile -->|WebSocket| CloudWorker
ServerRuntime -->|federation| ServerRuntime
ServerRuntime -.->|identity lookup| PortalWorker
ServerRuntime -.->|voice proxy| CFSFU
CloudWorker -.->|identity lookup| PortalWorker
CloudWorker -.->|voice proxy| CFSFU
Note: The Electron app embeds
ServerRuntimein the main process — it functions as both client and server simultaneously. Web and Mobile clients connect to remote servers.
Beta scope: Electron + Self-hosted Server + Portal + Web UI. Cloud Worker and Mobile deferred to post-beta.
graph LR
UIApp["@harmony/ui-app<br/>(SolidJS SPA)"]
UIApp -->|BrowserWindow| Electron["Electron<br/>+ embedded server<br/>+ SQLite"]
UIApp -->|served as SPA| Web["Web Browser"]
UIApp -->|Capacitor shell| Mobile["Android / iOS<br/>+ native push<br/>+ biometric<br/>(post-beta)"]
36 packages organised in five layers. Key dependency edges shown (not exhaustive).
graph LR
crypto["@harmony/crypto<br/>(Ed25519, X25519, HKDF)"]
did["@harmony/did<br/>(DID:key creation)"]
quads["@harmony/quads<br/>(RDF quad store)"]
vocab["@harmony/vocab<br/>(namespaces, predicates)"]
crdt["@harmony/crdt<br/>(Lamport clocks)"]
protocol["@harmony/protocol<br/>(message types)"]
did --> crypto
quads --> vocab
protocol --> crdt
graph TB
subgraph "Identity & Auth"
identity["@harmony/identity"]
vc["@harmony/vc"]
zcap["@harmony/zcap"]
credentials["@harmony/credentials"]
end
subgraph Communication
client["@harmony/client"]
server["@harmony/server"]
e2ee["@harmony/e2ee"]
voice["@harmony/voice"]
search["@harmony/search"]
media["@harmony/media"]
moderation["@harmony/moderation"]
end
identity --> did
identity --> crypto
vc --> crypto
vc --> did
zcap --> crypto
credentials --> vc
credentials --> zcap
client --> protocol
client --> e2ee
client --> identity
server --> protocol
server --> quads
server --> vc
server --> zcap
server --> moderation
e2ee --> crypto
voice --> protocol
media --> e2ee
graph TB
subgraph Infrastructure
serverRuntime["@harmony/server-runtime"]
cloud["@harmony/cloud"]
cloudWorker["@harmony/cloud-worker"]
portal["@harmony/portal"]
portalWorker["@harmony/portal-worker"]
federation["@harmony/federation"]
end
subgraph Applications
app["@harmony/app (Electron)"]
uiApp["@harmony/ui-app (SolidJS)"]
ui["@harmony/ui (components)"]
cli["@harmony/cli"]
cliApp["@harmony/cli-app"]
mobile["@harmony/mobile"]
end
subgraph Migration
migration["@harmony/migration"]
migrationBot["@harmony/migration-bot"]
discordBot["@harmony/discord-bot"]
end
subgraph Governance
governance["@harmony/governance"]
botApi["@harmony/bot-api"]
end
serverRuntime --> server
cloudWorker --> cloud
cloudWorker --> server
portalWorker --> portal
app --> serverRuntime
app --> uiApp
uiApp --> ui
uiApp --> client
mobile --> uiApp
cliApp --> cli
cli --> client
migration --> quads
migrationBot --> migration
federation --> server
federation --> protocol
flowchart LR
A["Generate Mnemonic<br/>(BIP-39, 24 words)"] --> B["Derive Seed<br/>(PBKDF2)"]
B --> C["Ed25519 Keypair<br/>(@harmony/crypto)"]
C --> D["DID:key Document<br/>(@harmony/did)"]
D --> E["Identity Object<br/>(@harmony/identity)"]
E --> F{"Storage Target"}
F -->|Browser/Mobile| G["localStorage"]
F -->|Electron/Server| H["Config File"]
graph TB
DIDDoc["DID Document"]
DIDDoc --> id["id: did:key:z6Mk..."]
DIDDoc --> vm["verificationMethod"]
DIDDoc --> ka["keyAgreement"]
vm --> vmEntry["Ed25519VerificationKey2020<br/>(signing)"]
ka --> kaEntry["X25519KeyAgreementKey2020<br/>(encryption / DH)"]
sequenceDiagram
participant Owner
participant Guardian1
participant Guardian2
participant Server
Note over Owner: Setup Phase
Owner->>Server: Register guardian DIDs (threshold: 2/3)
Note over Owner: Recovery Phase
Owner->>Guardian1: Request recovery approval
Owner->>Guardian2: Request recovery approval
Guardian1->>Server: Approve recovery (signed)
Guardian2->>Server: Approve recovery (signed)
Server->>Owner: Threshold met — recovery key released
Owner->>Owner: Reconstruct identity from new mnemonic + recovery
Note:
@harmony/cryptoprovides all primitives (Ed25519, X25519, HKDF).@harmony/didcreates DID documents.@harmony/identitymanages the full lifecycle including persistence and recovery.
sequenceDiagram
participant Issuer
participant Subject
participant Verifier
Issuer->>Issuer: Create VC (type, subject, claims)
Issuer->>Issuer: Sign with Ed25519 private key
Issuer->>Subject: Deliver signed VC
Subject->>Subject: Bundle VCs into VP
Subject->>Subject: Sign VP with holder key
Subject->>Verifier: Present VP
Verifier->>Verifier: Verify VP signature
Verifier->>Verifier: Verify each VC signature
Verifier->>Verifier: Check revocation status
Verifier->>Verifier: Check expiry
Verifier->>Verifier: ✓ Accept or ✗ Reject
graph TB
subgraph "VC Types"
IC["IdentityCredential<br/>(username, avatar)"]
MC["MembershipCredential<br/>(community access)"]
end
subgraph "Issuer Policies"
Open["open<br/>(anyone can issue)"]
AdminOnly["admin-only<br/>(admin DIDs only)"]
RoleBased["role-based<br/>(requiredRole check)"]
end
MC -.->|gated by| AdminOnly
MC -.->|gated by| RoleBased
IC -.->|gated by| Open
Note: Revocation store is currently in-memory. Future work includes persistent revocation, VC-based community admission gates, cross-community trust chains, and E2EE key binding to VCs.
flowchart TD
Root["Root Capability<br/>(community creator)<br/>actions: [*]<br/>scope: { community: id, channel: * }"]
Root -->|delegate + attenuate| Admin["Admin Capability<br/>actions: [message.send, channel.create, ...]<br/>scope: { community: id, channel: * }"]
Admin -->|delegate + attenuate| Member["Member Capability<br/>actions: [message.send]<br/>scope: { community: id, channel: general }"]
flowchart LR
Req["Incoming Mutation"] --> A["Verify Invoker Signature"]
A -->|✗| Deny["403 Denied"]
A -->|✓| B["Check Scope Matches"]
B -->|✗| Deny
B -->|✓| C["Check Action Allowed"]
C -->|✗| Deny
C -->|✓| D["Check Revocation"]
D -->|revoked| Deny
D -->|✓| E["Walk Parent Chain"]
E -->|invalid| Deny
E -->|✓| Allow["✓ Execute"]
Note: Every mutation passes through ZCAP verification. Caveats (time-limited, rate-limited) are designed but not yet enforced.
sequenceDiagram
participant Client
participant Server
Client->>Server: WebSocket connect
Client->>Client: Bundle VCs into VP, sign with DID key
Client->>Server: sync.state { presentation: VP }
Server->>Server: Verify VP signature
Server->>Server: Resolve DID document
Server->>Server: Verify proof against public key
Server->>Server: Bind connection to holder DID
Server->>Client: sync.state.response { communities, channels, members }
Note over Client,Server: All subsequent messages<br/>authenticated by bound DID
Note: The VP handshake replaces traditional username/password login. The server never sees private keys — only signed proofs.
sequenceDiagram
participant Alice
participant Server
participant Bob
Note over Alice: Creates community
Alice->>Server: mls.group.setup (groupId = communityId:channelId)
Note over Bob: Joins community
Bob->>Server: Upload KeyPackage (did, sigKey, encKey, sig)
Server->>Alice: mls.member.joined (Bob's DID)
Alice->>Server: Fetch Bob's KeyPackage
Alice->>Alice: group.addMember(Bob)
Alice->>Server: Welcome (to Bob) + Commit (broadcast)
Bob->>Bob: joinFromWelcome(welcome, encKP, sigKP)
Note over Alice,Bob: Shared epoch secret established
Alice->>Alice: group.encrypt(plaintext)
Alice->>Server: MLSCiphertext { groupId, epoch, senderIndex, ciphertext }
Server->>Bob: Forward MLSCiphertext
Bob->>Bob: group.decrypt(ciphertext)
graph LR
E0["Epoch 0<br/>(plaintext — backward compat)"]
E0 -->|addMember / removeMember| E1["Epoch 1<br/>(MLS encrypted)"]
E1 -->|member change| E2["Epoch 2"]
E2 -->|member change| EN["Epoch N"]
PQ["Pending Queue<br/>(messages before Welcome)"]
PQ -.->|replayed after join| E1
Note: Epoch 0 means plaintext (for backward compatibility). Epoch > 0 = MLS encrypted.
processCommitguards against stale epochs. Client dedup via_pendingMemberDIDsMap.
sequenceDiagram
participant Alice
participant Server
participant Bob
Alice->>Server: Fetch Bob's encryption public key (X25519)
Alice->>Alice: X25519 ECDH → shared secret
Alice->>Alice: XChaCha20-Poly1305 encrypt (random nonce)
Alice->>Server: Encrypted DM (addressed by DID)
Server->>Bob: Forward (server is blind relay)
Bob->>Bob: X25519 ECDH → same shared secret
Bob->>Bob: Decrypt with nonce
flowchart TD
Upload["Upload Media"]
Upload --> HasMLS{"MLS Group Available?"}
HasMLS -->|Yes| MLSKey["deriveMediaKey()"]
HasMLS -->|No| HKDF["HKDF Fallback Key"]
MLSKey --> Encrypt["Encrypt Media"]
HKDF --> Encrypt
Encrypt --> Store["Upload Ciphertext"]
Download["Download Media"] --> Fetch["Fetch Ciphertext"]
Fetch --> Decrypt["Decrypt with same key derivation"]
sequenceDiagram
participant Sender
participant Server
participant MemberA
participant MemberB
Sender->>Server: ProtocolMessage { id, type: "message.send", timestamp, sender: DID, payload }
Server->>Server: ZCAP check
Server->>Server: Moderation check
Server->>Server: Lamport clock merge
Server->>Server: Store as RDF quads
Server->>MemberA: Broadcast (exclude sender)
Server->>MemberB: Broadcast (exclude sender)
graph TB
PM["ProtocolMessage"]
PM --> id["id: string (UUID)"]
PM --> type["type: message.send | edit | delete | reaction | typing | sync"]
PM --> ts["timestamp: string (ISO 8601)"]
PM --> sender["sender: DID string"]
PM --> payload["payload"]
payload --> plain["plaintext content"]
payload --> enc["EncryptedContent { ciphertext, epoch, senderIndex }"]
subgraph CRDT Layer
LC["LamportClock { counter, authorDID }"]
end
Note: The ProtocolMessage timestamp is an ISO 8601 string. Lamport clocks operate in the CRDT layer for causal ordering of state mutations, separate from message timestamps. The CRDT layer ensures convergent state even with out-of-order delivery.
flowchart LR
Create["Create Community<br/>(root ZCAP issued)"] --> Invite["Generate Invite Code"]
Invite --> Join["Member Joins<br/>(VP auth + invite)"]
Join --> Channels["Access Channels"]
Channels --> Text["Text Channel"]
Channels --> Voice["Voice Channel"]
Channels --> Announce["Announcement Channel"]
graph TB
subgraph "Quad: (Subject, Predicate, Object, Graph)"
Q1["(community:abc, harmony:name, 'My Server', community:abc)"]
Q2["(community:abc, harmony:hasMember, did:key:z6Mk..., community:abc)"]
Q3["(channel:xyz, harmony:type, 'text', community:abc)"]
Q4["(message:123, harmony:content, 'Hello!', channel:xyz)"]
Q5["(role:admin, harmony:hasPermission, 'channel.create', community:abc)"]
end
flowchart LR
Admin["Admin Role"] -->|assign| Member["Member DID"]
Admin -->|create/delete| Roles["Custom Roles"]
Roles -->|grant| Perms["Permissions<br/>(message.send, channel.create, ...)"]
Server["Server"] -->|check on every action| Perms
All voice/video uses Cloudflare Realtime SFU — a managed, globally-distributed forwarding-only SFU. No mediasoup. Self-hosted and cloud deployments both proxy CF API calls through the Harmony server.
graph LR
subgraph "Client A"
ProducerA["Audio/Video Producer"]
ConsumerA["Remote Consumers"]
end
subgraph "Harmony Server"
Proxy["callCFApi() Proxy"]
TrackReg["voiceTrackRegistry"]
end
subgraph "CF Realtime SFU"
SessionA["Session A"]
SessionB["Session B"]
end
subgraph "Client B"
ProducerB["Audio/Video Producer"]
ConsumerB["Remote Consumers"]
end
ProducerA -->|voice.tracks.push| Proxy -->|tracks/new| SessionA
SessionA -->|forwarded tracks| SessionB
SessionB -->|voice.tracks.pull| Proxy -->|ontrack| ConsumerB
ProducerB -->|voice.tracks.push| Proxy -->|tracks/new| SessionB
SessionB -->|forwarded tracks| SessionA
SessionA -->|voice.tracks.pull| Proxy -->|ontrack| ConsumerA
sequenceDiagram
participant Client
participant Server
participant CFSFU["CF Realtime SFU"]
Client->>Server: voice.token (channel ID)
Server->>Client: token + mode ('cf' | 'signaling')
Client->>Server: voice.session.create
Server->>CFSFU: POST /sessions/new
CFSFU->>Server: sessionId
Server->>Client: voice.session.created (sessionId)
Client->>Server: voice.tracks.push (SDP offer)
Server->>CFSFU: POST /sessions/:id/tracks/new
CFSFU->>Server: SDP answer
Server->>Client: voice.tracks.pushed (SDP answer)
Server-->>Others: voice.track.published (trackName, sessionId)
Note over Client: ICE negotiation via RTCPeerConnection
Client->>Client: AnalyserNode → speaking detection
Client->>Server: voice.speaking { speaking: true }
graph TB
subgraph "Server / Cloud Worker"
callCFApi["callCFApi(method, path, body)"]
TrackReg["voiceTrackRegistry<br/>Map<channelId, Map<connId, TrackInfo[]>>"]
end
subgraph "CF Realtime API"
Base["https://rtc.live.cloudflare.com/v1/apps/{appId}/..."]
SessionNew["POST /sessions/new"]
TracksNew["POST /sessions/:id/tracks/new"]
TracksClose["PUT /sessions/:id/tracks/close"]
Renegotiate["PUT /sessions/:id/renegotiate"]
end
callCFApi -->|Authorization: Bearer secret| SessionNew
callCFApi --> TracksNew
callCFApi --> TracksClose
callCFApi --> Renegotiate
callsApiBaseis configurable inServerConfig(defaults tohttps://rtc.live.cloudflare.com)CALLS_APP_IDandCALLS_APP_SECRETmust be set (env vars or wrangler secrets)- Without CF credentials, voice operates in signaling-only mode (no actual media)
- Server stores per-participant track info in
voiceTrackRegistryfor late-joiner support
stateDiagram-v2
[*] --> Active
Active --> Muted: mute()
Muted --> Active: unmute()
Active --> Deafened: deafen()
Deafened --> Active: undeafen()
Muted --> Deafened: deafen()
state Active {
[*] --> Producing
Producing: Audio producer active
}
state Muted {
[*] --> ProducerStopped
ProducerStopped: Producer + tracks stopped
}
state Deafened {
[*] --> AllPaused
AllPaused: All consumers paused
}
Cloudflare Realtime SFU is the sole SFU backend. The
ClientSFUAdapterinterface enables test mocking. E2EE via Insertable Streams encrypts/decrypts media frames at the sender/receiver — the SFU forwards encrypted frames transparently.
graph TB
Interface["QuadStore Interface<br/>(add, remove, match, query)"]
Interface --> Memory["MemoryQuadStore<br/>(client-side)"]
Interface --> SQLiteQS["SQLiteQuadStore<br/>(server-runtime)"]
Interface --> DOQS["DOQuadStore<br/>(cloud worker DO SQLite)"]
subgraph "Stored as Quads"
Communities["Communities"]
Channels["Channels"]
Members["Members"]
Messages["Messages"]
Roles["Roles & Permissions"]
DIDs["DID Documents"]
end
Interface --> Communities
Interface --> Channels
Interface --> Members
Interface --> Messages
graph LR
Vocab["@harmony/vocab"]
Vocab --> NS["Namespaces<br/>(harmony:, did:, vc:, zcap:)"]
Vocab --> Pred["Predicates<br/>(harmony:name, harmony:hasMember,<br/>harmony:content, harmony:type, ...)"]
Vocab --> Types["Types<br/>(Community, Channel, Message, Role)"]
sequenceDiagram
participant Client
participant Server
Client->>Server: WebSocket connected
Note over Client,Server: Normal operation
Server--xClient: Connection drops
loop Exponential Backoff
Client->>Client: Wait (1s, 2s, 4s, 8s...)
Client->>Server: Reconnect attempt
end
Client->>Server: WebSocket reconnected
Client->>Server: sync.state (VP + last known state)
Server->>Client: Full state restoration
Client->>Client: Drain offline message queue
Client->>Server: Buffered messages sent
graph TB
Client["Client"]
Client --> S1["Server 1 ✓"]
Client --> S2["Server 2 ✗ (dropped)"]
Client --> S3["Server 3 ✓"]
Client -->|status| Connected["Status: connected<br/>(not reconnecting)"]
Note["Partial disconnect: some servers<br/>drop but others remain — client<br/>shows 'connected' not 'reconnecting'"]
sequenceDiagram
participant MigrationBot
participant Discord
participant Server
participant Harmony
Note over MigrationBot,Discord: Export Phase
MigrationBot->>Discord: Join server
MigrationBot->>Discord: Fetch channels, messages, attachments, threads, reactions
MigrationBot->>MigrationBot: Transform to RDF quads
MigrationBot->>MigrationBot: Encrypt bundle
MigrationBot->>Server: Upload encrypted bundle (R2)
Note over Server,Harmony: Import Phase
Server->>Server: Decrypt bundle
Server->>Server: Insert quads
Server->>Server: Map Discord users → DIDs (ghost members for unmapped)
Server->>Server: Hash-based dedup (prevent re-import)
Server->>Harmony: Community populated
graph TB
subgraph "Portal Worker (Cloudflare)"
IdStore["Identity Store<br/>(D1: DID registration + lookup)"]
Directory["Community Directory<br/>(discover public communities)"]
Invites["Invite Resolver<br/>(short codes → connection info)"]
OAuth["OAuth Handler<br/>(Discord OAuth for identity linking)"]
RateLimit["Rate Limiter<br/>(KV: per-DID limiting)"]
Relay["Relay<br/>(DO: WebSocket proxy for restrictive NATs)"]
ExportStore["Export Store<br/>(R2: encrypted Discord bundles)"]
end
Client["Clients"] -->|DID lookup| IdStore
Client -->|discover| Directory
Client -->|resolve invite| Invites
Client -->|link Discord| OAuth
Client -.->|rate limited| RateLimit
Client -->|NAT traversal| Relay
MigBot["Migration Bot"] -->|upload| ExportStore
graph TB
subgraph "Client-Side FTS"
Decrypt["Decrypt Messages<br/>(E2EE — server can't see plaintext)"]
Tokenizer["Tokenizer"]
Index["Inverted Index"]
QueryParser["Query Parser"]
Decrypt --> Tokenizer --> Index
QueryParser --> Index
end
subgraph "Server-Side"
MetaSearch["Metadata Search<br/>(timestamps, senders, channels)"]
end
subgraph "UI"
Overlay["Search Overlay"]
Results["Result Navigation"]
Highlights["Highlights"]
Overlay --> Results --> Highlights
end
Index --> Overlay
MetaSearch --> Overlay
Note: Full-text search of message content is client-side only (E2EE constraint). Server can only search metadata. 39 tests cover the tokenizer, index, and query parser.
flowchart TD
Action["Incoming Action"] --> BanCheck{"Banned?"}
BanCheck -->|Yes| Block["Block"]
BanCheck -->|No| RateCheck{"Rate Limited?"}
RateCheck -->|Yes| Block
RateCheck -->|No| SlowMode{"Slow Mode Cooldown?"}
SlowMode -->|Active| Block
SlowMode -->|Clear| AgeCheck{"DID Age Sufficient?"}
AgeCheck -->|No| Block
AgeCheck -->|Yes| VCCheck{"Required VCs Present?"}
VCCheck -->|No| Block
VCCheck -->|Yes| Allow["✓ Allow"]
RaidDetect["Raid Detection<br/>(rapid join threshold)"] -->|triggered| AutoLock["Auto-Lockdown"]
sequenceDiagram
participant Sender
participant Server
participant Recipient
Sender->>Server: message.send (contains @mention or is DM/reply)
Server->>Server: Detect mention (@username or @did:key:...)
Server->>Server: Create notification record
Server->>Recipient: Push via WebSocket
Recipient->>Server: notification.list
Server->>Recipient: Unread notifications
Recipient->>Server: notification.mark-read { id }
Recipient->>Server: notification.mark-all-read
Recipient->>Server: notification.count
graph LR
Bell["NotificationBell<br/>(unread count badge)"]
Bell -->|click| Dropdown["Notification Dropdown<br/>(list of notifications)"]
Dropdown --> MarkRead["Mark Read"]
Dropdown --> MarkAll["Mark All Read"]
Dropdown --> Navigate["Navigate to Message"]
graph TB
subgraph "pnpm Monorepo (36 packages)"
Source["TypeScript Source"]
end
Source --> Electron["Electron<br/>esbuild → electron-builder<br/>→ DMG / AppImage"]
Source --> Docker["Docker<br/>server-runtime image"]
Source --> CF["Cloudflare<br/>wrangler deploy<br/>portal-worker + cloud-worker"]
Source --> Cap["Capacitor<br/>Android APK / iOS IPA"]
subgraph "CI Pipeline"
Vitest["vitest<br/>(2582 tests)"]
Playwright["Playwright<br/>(99 E2E tests)"]
Lint["oxlint"]
TSC["tsc (type check)"]
end
Source --> Vitest
Source --> Playwright
Source --> Lint
Source --> TSC
flowchart LR
MainTS["main process TS"] -->|esbuild| Bundle["harmony-app.js"]
Preload["preload.ts"] -->|esbuild| PreloadJS["preload.js<br/>(contextBridge → __HARMONY_DESKTOP__)"]
UIApp["ui-app build"] --> Renderer["BrowserWindow renderer"]
Bundle --> ElectronBuilder["electron-builder"]
PreloadJS --> ElectronBuilder
Renderer --> ElectronBuilder
ElectronBuilder --> DMG["DMG (macOS)"]
ElectronBuilder --> AppImage["AppImage (Linux)"]
Note["nodeIntegration: false<br/>contextIsolation: true"]
Clients don't discover servers autonomously — the HarmonyClient.connect(url, options?) method requires a WebSocket URL. Discovery happens at the UI/application layer through five distinct paths.
graph TB
User([User])
subgraph "Discovery Paths"
Embedded["🖥️ Electron Embedded<br/>auto-start ServerRuntime<br/>→ ws://localhost:{port}"]
Manual["⌨️ Manual URL Entry<br/>Advanced option in UI"]
Invite["🔗 Invite Code<br/>Short code → Portal lookup"]
Directory["📋 Community Directory<br/>Browse public communities"]
Relay["🔀 Relay Proxy<br/>NAT traversal via Portal"]
end
subgraph "Portal Worker (Cloudflare)"
InviteReg["InviteRegistryDO"]
CommunityReg["CommunityRegistryDO"]
RelayDO["RelayDO<br/>WebSocket proxy"]
end
Server["Harmony Server"]
User --> Embedded --> Server
User --> Manual --> Server
User --> Invite --> InviteReg --> Server
User --> Directory --> CommunityReg --> Server
User --> Relay --> RelayDO --> Server
-
Electron embedded —
ServerRuntimestarts in-process on port 4515 (host0.0.0.0). After the window loads,harmony:server-startedIPC sends the server URL. The renderer callswindow.__HARMONY_DESKTOP__.waitForServer()thenstore.addServer(serverUrl)to auto-connect. Identity is restored from disk-persisted config. -
Persisted server reconnect —
LocalStoragePersistencesaves{ servers: [{ url, communityIds[] }], did, encryptionKeyPair }. OnHarmonyClient.create(), the client auto-reconnects to all previously-saved servers. This is the primary reconnection path for web clients across sessions. -
Manual URL entry —
store.addServer(url)/client.addServer(url)connects to any server URL. Used by the migration wizard and available as a manual option post-onboarding (not in the onboarding flow itself). -
Invite code resolution — The primary social discovery path:
sequenceDiagram
participant U as User
participant UI as UI App
participant PC as PortalClient
participant PW as Portal Worker
participant IR as InviteRegistryDO
participant S as Harmony Server
U->>UI: Enter invite code "abc123"
UI->>PC: resolveInvite("abc123")
PC->>PW: GET /invite/abc123
PW->>IR: lookup(code)
IR-->>PW: {serverUrl, communityId, communityName}
PW-->>PC: InviteResolution
PC-->>UI: {serverUrl, inviteCode}
UI->>S: WebSocket connect + community.join with VP & inviteCode
S-->>UI: Community state (channels, members, roles)
-
Community directory — Portal Worker
directory.list()returns communities with endpoints (name, description, member count, server URL). Servers self-register viadirectory.register()with{ communityId, name, endpoint, memberCount, inviteCode, ownerDID }. Backend fully implemented; UI browse integration not yet visible in onboarding. -
Relay fallback —
RelayDurableObjectprovides bidirectional WebSocket proxy for NAT traversal. Node connects first, then clients connect and messages are proxied. Currently scaffolded but not production-enabled (relay: { enabled: false }in server config).
HarmonyClient supports simultaneous connections to multiple servers via _servers: Map<string, ServerConnection>. A _communityServerMap tracks which community lives on which server. The connect() method takes { serverUrl, identity, keyPair, vp? } — if no VP is provided, one is auto-created from the identity/keyPair.
When a server creates an invite with portal: true, the portal-worker's invite-resolver stores the mapping: short code → { communityId, endpoint, preview: { name, description, memberCount } }. Invite codes support expiry, max uses, revocation, and use counting.
The OnboardingView offers four paths:
- Create Identity → generates mnemonic → backup quiz → setup (display name + optional Discord link via portal OAuth)
- Recover Identity → enter 12-word mnemonic OR social recovery (initiation works; completion not yet connected to backend)
- Import from Discord → create identity first, then MigrationWizard
- Sign in via Portal → portal URL input, Discord OAuth, identity creation + linking
Server connection happens separately from onboarding — via embedded auto-connect (desktop), persisted reconnect, or manual addServer().
| Path | Status |
|---|---|
| Electron embedded auto-connect | ✅ Implemented |
| Persisted server reconnect | ✅ Implemented |
| Manual server URL | ✅ Implemented |
| Invite code resolution | ✅ Implemented |
| Community directory | ✅ Backend implemented; UI browse partial |
| Portal OAuth sign-in | ✅ Implemented |
| Relay proxy (NAT traversal) | 🟡 Scaffolded (enabled: false) |
| Federation | 🟡 Scaffolded (enabled: false) |
Deep links (harmony://) |
|
| QR code sharing | ❌ Planned |
| Local network discovery (mDNS) | ❌ Not implemented |
Note: Auto-reconnect uses max 5 attempts. The
LocalStoragePersistenceadapter ensures web clients reconnect to all saved servers across sessions.
graph TB
subgraph "Client (Browser/Electron Renderer)"
LS["localStorage<br/>harmony:client:state"]
Mem["In-Memory (ephemeral)"]
end
subgraph "localStorage Contents"
ID["did + mnemonic"]
EK["encryptionKeyPair<br/>X25519 (publicKey + secretKey)"]
SL["servers<br/>[{url, communityIds[]}]"]
end
subgraph "In-Memory (lost on refresh)"
MQ["Channel logs, DM channels,<br/>thread messages"]
MLS["mlsGroups Map<br/>Epoch keys"]
end
LS --- ID
LS --- EK
LS --- SL
Mem --- MQ
Mem --- MLS
subgraph "Server (SQLite + filesystem)"
DB["harmony.db (WAL mode)<br/>Quads: messages, channels,<br/>roles, members"]
ATT["./media/<br/>Uploaded files"]
BK["backup(path)<br/>Hot backup available"]
end
subgraph "Cloud Worker (Cloudflare)"
DO["DO SQLite<br/>Quads + members/channels/<br/>voice/keypackages tables"]
R2["R2 Bucket<br/>Media attachments"]
end
DB -.->|"better-sqlite3<br/>.backup()"| BK
- SQLite config: WAL mode,
synchronous = NORMAL, foreign keys enabled - Database path: Configurable via
storage.databasein YAML config (default./harmony.db) - Hot backup:
ServerRuntimeexposes abackup(path)method using better-sqlite3's online backup API — but nothing calls it on a schedule - Compaction:
compact()method runs VACUUM + WAL checkpoint - Stats: Size tracking available via
stats()method
All client state persisted under localStorage key harmony:client:state as a single PersistedState object:
did— user's DID stringservers— array of{ url, communityIds[] }encryptionKeyPair—{ publicKey: number[], secretKey: number[] }(X25519)
Everything else is in-memory only: channel message logs, DM channels, thread messages, MLS group state (mlsGroups Map). Closing the tab loses all message cache and E2EE state.
| Deployment | Data Store | Durability | Risk |
|---|---|---|---|
| Electron | SQLite in app data dir (e.g. ~/Library/Application Support/Harmony/) |
Survives restart; hot backup API available | Lost on uninstall; no auto-backup |
| Cloud (DO) | Cloudflare DO SQLite (auto-replicated) + R2 | High — Cloudflare manages replication | No user-controlled export; data locked in platform |
| Self-hosted Docker | SQLite in mounted volume | Depends on volume management | Operator responsible for backups; backup() API available |
| Web client | Browser localStorage | Fragile | Cleared on browser data wipe; silent failure on quota errors |
Three recovery paths exist:
- Mnemonic (12 BIP-39 words) —
createFromMnemonic(mnemonic)deterministically recreates Ed25519 keypair and DID. Does NOT recover: X25519 encryption keypair, server list, message history, MLS state - OAuth recovery —
createFromOAuthRecovery(provider, token)derives deterministic keypair from OAuth token - Social recovery —
setupRecovery()with trusted DIDs + threshold, multi-sig approval viainitiateRecovery()/approveRecovery()/completeRecovery(). Data-structure only — no server-side persistence of recovery configs yet
Additionally, exportSyncPayload() encrypts identity (DID doc + credentials + capabilities) with a keypair-derived key; importSyncPayload() decrypts with mnemonic — designed for multi-device sync.
The @harmony/migration package provides:
- Discord server export → Harmony import: Full transformation of channels, messages, members, roles, reactions, attachments, stickers, embeds, threads into RDF quads
- Encrypted export bundles (
EncryptedExportBundle): Quads encrypted with admin's keypair via HKDF-derived symmetric key (harmony-export-salt-v1) - Decrypt export: Recovery of quads from encrypted bundles
- Re-sign community credentials on migration
Note: This is Discord→Harmony import only. There is no Harmony-native community export that dumps a running community to a portable bundle.
| Gap | Impact | Status |
|---|---|---|
| No Harmony-native community export | Cannot migrate or back up a running community | Post-launch roadmap |
| No automated server backup | backup() API exists but nothing schedules it |
Operator must script |
| No message history export | Users cannot download their messages | Not planned |
| Client message cache in-memory only | All messages lost on tab close/refresh | No IndexedDB/offline cache |
| MLS state is ephemeral | Groups rebuilt on reconnect; old epoch messages unrecoverable | By design (forward secrecy) |
| No multi-device identity sync | Identity lives in one browser's localStorage | Sync payload API exists but no automated flow |
| Encryption keypair not in mnemonic | Losing localStorage = losing MLS decryption ability | X25519 generated independently |
| DO data not exportable | Cloud communities locked in Cloudflare | No data portability path |
| Social recovery not server-persisted | Recovery config exists as data structures only | Needs server-side storage |
| Encryption keys unprotected | Private keys in localStorage with no passphrase/hardware backing | Security gap |
Note: Beta users should be advised:
- Back up your mnemonic — primary identity recovery path (but does not recover encryption keys or messages)
- Self-hosted operators: script
backup()— the hot backup API exists, call it on a cron schedule and ship offsite- Electron is the most durable client — web browser localStorage can be wiped unexpectedly
- Message history is server-side only — if a server is lost without backup, its messages are gone
- MLS encryption means old messages may become unreadable if client state is lost — this is a trade-off of forward secrecy
- Cloud (DO) communities have no export path — consider self-hosted for data sovereignty requirements
Both @harmony/server (used by server-runtime and Electron) and cloud-worker (CommunityDurableObject on Cloudflare) implement the Harmony WebSocket protocol. Cloud-worker now covers ~71 of ~75 message types (~95%), up from the original ~13.
graph LR
subgraph "Shared Packages"
Protocol["@harmony/protocol<br/>serialise/deserialise"]
Vocab["@harmony/vocab<br/>Predicates, types"]
end
subgraph "Server Package"
Server["@harmony/server<br/>HarmonyServer class<br/>QuadStore (async)<br/>Full VP + VC + ZCAP auth<br/>~75 message handlers"]
end
subgraph "Cloud Worker"
CW["cloud-worker<br/>CommunityDurableObject<br/>DOQuadStore (sync) + SQL tables<br/>Simplified VP auth (WebCrypto)<br/>~71 message handlers"]
end
Protocol --> Server
Protocol --> CW
Vocab --> Server
Vocab --> CW
Zero handler code is shared. Auth, storage, broadcast, and every message handler are reimplemented independently for the Cloudflare DO runtime.
| Aspect | Server | Cloud Worker |
|---|---|---|
| Auth mechanism | VP sent as first message with sync.state |
Raw VP JSON sent as first message |
| VP verification | Full VCService.verifyPresentation() + embedded VC verification (expiration, revocation) |
Ed25519 signature check only via WebCrypto — no VC verification, no revocation |
| DID resolution | Configurable DIDResolver, supports multiple methods |
did:key only (extracts public key inline) |
| ZCAP authorization | Full ZCAPService invocation verification |
None |
| Ban checking | In-memory ban list checked on send/join | No ban support |
| Moderation | Full plugin (slow mode, rate limit, raid detection, account age, VC requirements) | None |
| Auth timeout | 30s setTimeout |
30s DO alarm |
| Category | Message Type | Server | Cloud Worker |
|---|---|---|---|
| Channel | channel.send |
✅ | ✅ |
channel.edit |
✅ | ✅ | |
channel.delete |
✅ | ✅ | |
channel.typing |
✅ | ✅ | |
channel.create |
✅ | ✅ | |
channel.update |
✅ | ❌ | |
channel.delete.admin |
✅ | ❌ | |
channel.reaction.add/remove |
✅ | ❌ | |
channel.pin/unpin/pins.list |
✅ | ❌ | |
channel.history |
✅ | ❌ | |
| DM | dm.send/edit/delete/typing |
✅ | ❌ |
dm.keyexchange |
✅ | ❌ | |
| Community | community.create/join/leave |
✅ | ✅ |
community.update/info/list |
✅ | ❌ | |
community.ban/unban/kick |
✅ | ❌ | |
| Presence | presence.update |
✅ | ✅ |
| Sync | sync.request |
✅ | ✅ |
| MLS/E2EE | mls.keypackage.upload/fetch |
✅ | ❌ |
mls.welcome/commit/group.setup |
✅ | ❌ | |
| Voice | voice.join/leave/mute |
✅ | ✅ |
voice.session.create/tracks.push/pull |
✅ | ✅ | |
voice.tracks.close/renegotiate |
✅ | ✅ | |
voice.track.published/removed |
✅ | ✅ | |
voice.video/screen/speaking |
✅ | ✅ | |
| Threads | thread.create/send |
✅ | ❌ |
| Roles | role.create/update/delete/assign/remove |
✅ | ❌ |
| Media | media.upload.request/delete |
✅ | ❌ |
| Search | search.query |
✅ | ❌ |
| Notifications | notification.list/mark-read/count |
✅ | ❌ |
| Moderation | moderation.config.update/get |
✅ | ❌ |
| Member | member.update |
✅ | ❌ |
Score: Server supports ~75 message types. Cloud worker supports ~71 (~95%). Missing 4 are internal moderation triggers (accountAge, raidDetection, rateLimit, vcRequirement) that are server-internal and not externally signalled.
- Server: Async
QuadStoreabstraction backed bybetter-sqlite3 - Cloud Worker: Hybrid approach —
DOQuadStore(sync, backed by DO SQLite) plus direct SQL tables for members, channels, and voice participants - Handler connection signatures differ: server uses
(conn: ServerConnection, msg), cloud worker uses(ws: WebSocket, meta: ConnectionMeta, msg)
- Server: Room-based broadcast — only sends to clients subscribed to the relevant channel
- Cloud Worker: Broadcasts to all via
getWebSockets(), filtering client-side (less efficient at scale)
- Playwright E2E tests (99 tests) run exclusively against
server-runtime - No E2E tests target cloud-worker
- Cloud-worker unit tests are minimal (mostly auth verification)
- No conformance test suite exists to verify both backends behave identically
Note:
- Cloud worker is now near-parity — 71/75 message handlers implemented, covering text, DMs, voice (CF SFU proxy), E2EE (MLS), threads, roles, pins, reactions, search, notifications, media, and moderation
- A conformance test suite is strongly recommended — extract protocol tests into a backend-agnostic harness that runs against both implementations
- Auth simplification is a security concern — cloud worker skips VC verification, revocation checks, and ZCAP authorization. This should be documented as a known limitation
- Consider a shared handler layer — the current reimplementation approach guarantees divergence as features are added to server
- Broadcast efficiency in cloud-worker may need attention before communities scale past ~100 concurrent members
Architecture maintained by the Harmony team. Diagrams generated from codebase analysis — update when the structure changes.