Docs / Protocol spec

Developer docs

The ForkMesh mainnode protocol

ForkMesh is a peer-to-peer forge: desktop nodes hold the working copies, the git history, and the issue/PR/discussion records, and they talk to each other through a thin mainnode routing layer. The Worker never becomes a repository store. Default rooms use a relay-derived authenticated shared key, so the relay operator can decrypt room traffic. It coordinates generalized presence, selects healthy independently operated HTTPS mirror origins, proxies authorized public reads in place, verifies signed submissions, and keeps a public catalog so the network is discoverable.

Today one mainnode runs at forkmesh.com. This document specifies the protocol so that is a deployment, not the definition: anyone can stand up a mainnode from this spec and point a stock desktop client at it (see Self-hosting a mainnode). The reference relay is the Cloudflare Worker in cloudflare_worker/; the reference client is the Qt app in qt_client/.

Everything here is versioned by canonical-string prefix (forkmesh-<thing>-v1). A -v1 string is frozen: changing the bytes that go into a signature is a new version, never an edit, so old signatures keep verifying across a client rollout.

Companion docs: relay & website, desktop client, mobile app, IDE extension / its handshake protocol, and the changelog. The on-disk issue/PR/discussion record format lives in issues/README.md in the repo.

1. Identity and signatures

1.1 Node identity

Every desktop node starts with a long-lived Ed25519 identity key, generated on first run and stored as ed25519.pem in the node's config directory (qt_client/src/ForkMeshIdentity.h). This key is separate from the LAN TLS certificate: TLS encrypts peer links, the Ed25519 key signs metadata (profile, catalog records, issues, PRs, releases, auth tokens).

The public key is the identity — a raw 32-byte Ed25519 key, encoded base64url without padding. That base64url string is what appears as author / maintainer throughout the protocol and as the pubkey in every canonical string. Signatures are likewise raw Ed25519, base64url without padding.

A human-facing account name (e.g. newnewnode) can be bound to a key by reserving and finalizing it (§4.5). Account binding is what gates hosting, private repos, and catalog publishing — a bare key can browse and submit, but only the key registered to an account may claim to host owner/repo. An active account can rotate its bound key with POST /api/accounts/rotate; the old bound key signs the successor key, and the relay records the old key in prev_pubkeys for idempotent retries.

1.2 The canonical-string convention

ForkMesh never signs canonicalized JSON (that would invite C++/Python serialization drift). It signs an explicit newline-joined canonical string:

forkmesh-<thing>-v1 \n
<field 1> \n
<field 2> \n
...

Where a signature commits to variable-length content, the content is reduced to a single field by joining its parts with a NUL (\x00) and hashing: sha256hex(part1 \0 part2 \0 …). The signature then binds that hex digest, not the raw content, so the canonical string stays fixed-shape.

Both sides implement this identically: the client in the various *Store canonicalString / contentForSigning helpers, the relay in cloudflare_worker/src/entry.py (ed25519_verify + the per-type verify_* functions).

1.3 Signature schemes

Every signed action in the protocol. The relay verifies each with the pubkey named in the canonical string (ed25519_verify); a bad or missing signature is rejected before any state changes.

PurposeCanonical string (fields after the prefix line, \n-joined)
Issue eventissue-event type · number · author · ts · sha256hex(content)
Discussion eventdiscussion-event type · number · author · ts · sha256hex(content)
Pull requestpull-event author · ts · sha256hex(title \0 base \0 head \0 patch [\0 commits])
Pull commentpull-comment type · number · author · ts · sha256hex(content)
Commit commentcommit-comment sha · author · ts · sha256hex(body)
Catalog recordcatalog owner · repo · updatedAt
Catalog deletecatalog-delete owner · name · ts
Repo-state attestationrepostate owner · repo · stateHash · updatedAt
Release manifestrelease repo · tag · author · ts · sha256hex(content)
Account reservereserve name · ts
Account finalizefinalize name · email · ts
Account key rotationrotate oldPubkey · newPubkey · ts
Host tokenhost owner · repo · ts
View token (private clone/browse)view owner · repo · ts
Legacy push token (inactive)push owner · repo · ts; retained only for compatibility parsing while receive-pack is disabled
Share view token (collaborator)share-view viewer · owner · repo · ts
Node link (self)link-self nodeName · identifier · ts
Node link (grant)link-grant nodeName · ts

All prefixes are the literal forkmesh-<name>-v1. ts is milliseconds since the epoch; time-bound tokens Active host and view tokens are accepted only inside a short freshness window (_ts_ok). Direct mirror capabilities add a single-use request id and bind the method, path, body digest, node, and issue time. A syntactically valid legacy push token cannot activate receive-pack.

The distinct prefixes are load-bearing: a view token can never be replayed as a host or push token, and vice versa, even though the three share the same owner · repo · ts shape.

1.4 Issue / PR / discussion record signing

The on-disk record format lives in issues/README.md in the repo. The essentials the relay verifies for cross-user submissions:

canonical =
  forkmesh-issue-event-v1 \n <type> \n <number> \n <author-pubkey> \n <ts> \n
  sha256hex(content)

content is type-specific and joined by NUL. For an open event it is title \0 body \0 attachments.join(","); the body has leading/trailing newlines stripped so the blank line after frontmatter doesn't change the signature. See the per-type content table in issues/README.md. Issue signatures bind the issue number; PR signatures deliberately do not (the owner assigns the durable PR number on merge).

2. Rooms (encrypted coordination channels)

Nodes converge on shared rooms hosted by the mainnode's Durable Objects. Rooms carry presence, chat, and the small control frames that make the mesh feel live. The default room every node joins is mainnode/forkmesh → room general, i.e. the WebSocket path:

/api/repo/<owner>/<repo>/rooms/<room>/ws

Default rooms use relay-readable authenticated shared-key transport, not end-to-end encryption. Frames are AES-GCM ciphertext on the wire and in history, but the relay derives and distributes the shared passphrase and can therefore decrypt them.

2.1 Room key

The repository-scoped passphrase and room key are derived in two stages:

A desktop node signs forkmesh-room-key-v2\n<node>\n<lower-owner>\n<lower-repo>\n<timestamp-ms>. The old v1 node proof is accepted only for the public mainnode/forkmesh compatibility room. Browser clients use their bearer session; the passphrase response is never cacheable.

2.2 Administrator-created channels

Platform administrators can create named public or private chat channels. Public channels are available to every active registered user, but not to guests or node-only sessions. Private channels are available to administrators and explicitly selected active registered users. Administrators can select those initial users in the creation request, and D1 writes the channel and every membership in one atomic batch. Channel names, visibility, and member names are encrypted at rest, with blind indexes used for lookup and uniqueness.

The encrypted channel record contains a visibility value of public or private. Records created before this field existed default to private, so an upgrade never widens channel access. A public creation request cannot include membership rows. A private creation request can include up to 500 normalized registered usernames, and any invalid user rejects the complete operation without partial channel data.

A browser first lists its authorized channels through GET /api/chat/channels, then requests a non-cacheable passphrase and a 60-second signed WebSocket ticket through GET /api/chat/channels/<channel-id>/room-access. The WebSocket upgrade rechecks the active account, channel visibility, administrator or membership status when required, and current key version before reaching the Durable Object. Removing a private member atomically increments the key version, which selects a new passphrase and room namespace so the removed user cannot enter the rotated room.

These channels use the same relay-readable AES-GCM model as default rooms. Access control keeps other accounts out, and ciphertext is stored at rest, but the relay derives the passphrase and is not an end-to-end encryption boundary against the relay operator.

2.3 Message envelope

Each frame on the wire is JSON:

{ "kind": "cipher", "v": 1, "nonce": "<b64 12 bytes>", "tag": "<b64 16 bytes>", "body": "<b64 ciphertext>" }

The plaintext (before encryption) is the application JSON object — a chat message, an edit, a delete, a presence beacon, etc. AES-GCM uses the 12-byte nonce; the 16-byte tag is stored separately from the ciphertext body (the desktop client splits them the same way).

An encrypted attachment chat object uses fileName, fileMime, and a base64 file value. Browser clients accept clipboard images and selected image or document files up to 1 MiB, then encrypt those fields inside the ordinary room frame; there is no plaintext upload endpoint.

2.4 History retention

The relay keeps the last few days of encrypted frames so a late-joining node sees some backlog when no peer is online to replay it. Retention is opt-in per frame: only an envelope with persist: true is retained (the desktop client sets it for its durable message types). Limits: CHAT_HISTORY_RETAIN_MS = 7 days, CHAT_HISTORY_MAX_PER_ROOM = 500 frames, CHAT_HISTORY_MAX_BODY = 1,900,000 encrypted-frame bytes, and CHAT_HISTORY_MAX_BYTES_PER_ROOM = 16 MiB. Oldest frames are pruned when a room exceeds a time, count, or aggregate byte limit. History stores ciphertext envelopes, but the relay operator can derive the default room key and every private-channel room key, and can therefore decrypt them.

3. Catalog and state attestation

The catalog is the relay's public, at-rest-encrypted index of repositories, so the network page and clients can discover what is hosted and where. A node publishes a signed record for each repo it hosts.

3.1 Catalog record

POST /api/repo/<owner>/<repo>/mirrors (publish) carries a record plus two signatures. Key fields (safe_catalog_record):

FieldMeaning
owner, namelogical repo identity within this mainnode
visibilitypublic | private (anything but private is public)
rootCommitfirst/root commit — the network-wide group key that unites mirrors under different owners into one logical repo
sourcelocal-node (a working-copy holder / source of truth) or remote-clone (a mirror)
sizeBytes, description, cloneUrl, solana, hostedSince, lastSyncdescriptive
commit, branch, counts, platform, version, nodeId, clonesServed, websiteServedpoint-in-time node facts, shown even while the node is offline
maintainerthe publishing key (base64url pubkey)
stateHash, stateSigthe repo-state attestation (§3.2)

The record is accepted only if:

  1. catalogSig verifies for forkmesh-catalog-v1 \n owner \n name \n updatedAt against the account's registered key (maintainer must match).
  2. If present, stateSig verifies the attestation (§3.2) — a present but bad attestation rejects the whole write; absent is allowed for backward compatibility.
  3. updatedAt is not older than the stored record (rollback rejection).

The relay verifies only catalogSig and stateSig. Other fields (including the descriptive JSON signature) are stored as-is; extend the record freely without touching the verification path.

3.2 Repo-state attestation (the clone integrity gate)

To stop a tampered or rolled-back mirror from serving forged history, the owner signs a fingerprint of the refs it serves:

The relay pins verified attestations from source-of-truth (local-node) records into repo_state_history (newest N per repo). When a clone is served, the serving node's live ref advertisement must hash to a pinned value (clone_state_pins):

A mirror running a local agent whose branches diverge from the source fails the gate until its next pruning sync (~5 min) — clones of that mirror fail closed for that window, by design.

3.3 Presence

Signed node heartbeats provide generalized multiplayer and operator presence. Repository routing uses the separate mirror_https_endpoints registry: Cloudflare performs fresh signed health challenges and records latency and integrity without storing repository bytes.

Presence is intentionally a lagging, privacy-preserving status and is never treated as proof that a repository is safe. Clone, browse, and release routing require a fresh endpoint health result, a node-signed repository proof, and the repository integrity gate. The public network page reports generalized node availability rather than precise behavior.

4. HTTP API

All routes are rooted at the mainnode host. The route table is the single source of truth in cloudflare_worker/src/urls.py; Default._route in entry.py dispatches against it.

4.1 Rooms and direct-HTTPS routing

Method · pathPurpose
GET /api/room/<room>/ws | /clientsbare room WebSocket / live client count
GET /api/repo/<owner>/<repo>/rooms/<room>/ws | /clientsper-repo room WS / count (the mainnode general room lives here)
GET | POST /api/chat/channelslist authorized public and private channels or atomically create one with its private initial members
GET | POST | DELETE /api/chat/channels/<id>/membersadministrator-only private membership list, invitation, and revocation
GET /api/chat/channels/<id>/room-accessissue the current channel passphrase and short-lived ticket after visibility-aware authorization
GET /api/chat/channels/<id>/ws?ticket=…ticketed channel WebSocket with active-account, visibility, membership, and key-version admission checks
* /api/repo/<owner>/<repo>/hostlegacy control channel; repository payload frames fail closed
GET /api/repo/<owner>/<repo>/{tree,blob,blobs,raw,history,commit,branches,search}website browse — proxied over ordinary HTTPS to a healthy authenticated mirror origin

Room sockets carry only continuous interactions such as chat, presence, emotes, and small invalidation notices. They never carry repository clones, blobs, releases, or other large payloads. For repository reads, the Worker selects a healthy mirror and streams its HTTPS response in place under the original ForkMesh URL; the origin hostname is not exposed to the client.

4.2 Signed inboxes (cross-user submissions)

People without write access submit signed records; the owner's node drains, verifies, merges into git, and syncs back. Each is POST and each verifies with the matching §1.3 scheme.

PathSubmission
POST /api/repo/<owner>/<repo>/issuessigned issue open/comment/edit/status/…
POST /api/repo/<owner>/<repo>/pullssigned pull request
POST /api/repo/<owner>/<repo>/pulls/<number>/mergeaccount-session-authorized asynchronous merge, pinned to one write-enabled mirror and exact base/head/metadata object ids
POST /api/repo/<owner>/<repo>/commitssigned per-commit comment
POST /api/repo/<owner>/<repo>/discussionssigned discussion open/comment
POST /api/repo/<owner>/<repo>/subscribesigned thread subscribe/unsubscribe
POST /api/repo/<owner>/<repo>/bountyretired legacy bounty-custody path; returns a migration response and never creates a deposit wallet
* /api/repo/<owner>/<repo>/sharesowner-signed private-repo collaborator ACL

Submission itself is open to anyone: the author pubkey in an inbox record (§1.3) is whatever key the submitting node happens to hold — it does not need to be a registered account pubkey the way active host/view tokens do (§4.5). Verification here only proves the record wasn't tampered with after signing, not that the submitter is known or trusted. The owner reviewing the inbox is the actual gate: nothing merges into git or reaches the working copy without the owner draining, reviewing, and pushing it themselves (§4.4).

4.3 Catalog, mirrors, agents, releases

PathPurpose
POST /api/repo/<owner>/<repo>/mirrorspublish a signed catalog record (§3.1); GET reports mirror health
* /api/repo/<owner>/<repo>/agents · /agents/list · /agents/<id>/promptsigned owner-sealed session push/drain, owner-authorized ciphertext retrieval, and owner-sealed prompt queue; decrypted prompt objects are versioned and bind their repository owner/name, and decryption stays on the owner device
GET /api/repo/<owner>/<repo>/releases/blob/sha256/<hash>content-addressed release download through the direct-HTTPS mirror route (immutable and integrity checked)
GET /api/repo/<owner>/<repo>/releases/downloadsper-artifact download counts

4.4 Git smart-HTTP (clone / fetch)

PathPurpose
GET /<owner>/<repo>/info/refsclone/fetch ref advertisement (request 1 of 2)
POST /<owner>/<repo>/git-upload-packclone/fetch pack negotiation (request 2 of 2)
POST /<owner>/<repo>/git-receive-packdisabled until a direct-HTTPS write protocol is available; there is no WebSocket repository-data fallback

git clone https://<mainnode>/<owner>/<repo> works with a stock git for an actively mirrored public repository. Both upload-pack requests are capability-bound to an authenticated, healthy HTTPS gateway and retain the original ForkMesh URL. Private replica ciphertext is addressed only by an owner-authorized opaque handle and decrypted locally; a private repository name never enters the public routing index. Push currently fails closed with 501 direct_https_receive_pack_required. See §5 for the two-request read flow.

General receive-pack write support still requires a separate direct-HTTPS protocol. Pull merging uses a narrower control path: the Worker authorizes repository write permission, pins an idempotent request to one mirror, and signs a bounded request containing exact Git object ids. The node performs an atomic compare-and-swap, reseals the repository, and republishes signed integrity before the Worker reports completion. No repository payload travels through D1 or the multiplayer socket. The legacy push-token verifier does not make the retired socket transport safe and is not an active receive-pack path.

Object-producing merge work happens in an owner-only quarantine. Journal recovery anchors candidate objects behind hidden refs/forkmesh/ refs before repository fsck; conflicts never import objects, and a lost compare-and-swap leaves reachable staging refs instead of dangling objects. HTTP and SSH upload-pack hide that entire namespace and reject arbitrary object-id wants. Pull author signatures remain unchanged because only PullStore's established owner-applied lifecycle fields are updated. Worker merge jobs expire after seven days and have hard per-repository and global row limits.

4.5 Accounts and platform

PathPurpose
GET /api/accounts/<name>public account lookup: availability, registered pubkey, profile flags
POST /api/accounts/signupfree web signup from node name, email, and password
POST /api/accounts/reservename reservation, optionally signed by a desktop key
POST /api/accounts/finalizefinish a reserved account with email/password and optional key binding
POST /api/accounts/loginpassword/TOTP login; an optional pubkey registers the desktop device
POST /api/accounts/rotatereplace the account's bound pubkey after the current bound key signs forkmesh-rotate-v1
POST /api/accounts/profileprofile updates such as payout address, email verification resend, rename, and delete
POST /api/accounts/heartbeatsigned desktop presence and profile heartbeat
GET /api/version{ ok, rev, now } — the live BUILD_REV, used to verify a deploy (§6)
GET /api/network/stats · /online-history · /leaderboardscached homepage / network-page aggregates
GET /api/status30-day per-system uptime for the public status page

Account creation has two paths. The web path creates an active account directly through /api/accounts/signup from a node name, email, and password. The desktop/reservation path reserves a name (forkmesh-reserve-v1) and then finalizes it with email/password plus the key when one is bound (forkmesh-finalize-v1).

Once active, the account name can be bound to an Ed25519 key, and active host/view checks resolve the owner name to the registered pubkey before accepting a control or authorized private-replica action. There is no self-assertion: with no registered account, those gates fail closed. The legacy push-token verifier remains in the compatibility code, but receive-pack is disabled before any repository body can reach a socket.

POST /api/accounts/rotate accepts { nodeName?, oldPubkey, newPubkey, ts, sig|signature } and verifies forkmesh-rotate-v1\n<oldPubkey>\n<newPubkey>\n<ts> with the currently bound old key. A request that names the account and finds newPubkey already bound returns success as an idempotent no-op. nodeName is optional for desktop rotation records; when omitted, the relay finds the account by oldPubkey, and a repeat request after success can resolve by newPubkey when oldPubkey is already in prev_pubkeys. The rotation rejects stale requests, malformed keys, inactive or missing accounts, an oldPubkey that is not currently bound, a bad signature, and a newPubkey already bound to another account.

5. The two-request clone flow and sticky HTTPS selection

A git clone is two HTTP requests that must reach the same serving node:

  1. GET /<owner>/<repo>/info/refs — the ref advertisement.
  2. POST /<owner>/<repo>/git-upload-pack — pack negotiation against the refs the first request advertised.

If those two land on different nodes, the pack is negotiated against a different ref set and the clone breaks. The routing layer keeps the selected node sticky and limits every candidate to the same owner-attested refs state. The key idea is that the client's URL never changes: there is no redirect and no mirror hostname in the response. The Worker streams the selected HTTPS origin response in place without materializing it.

Decision order for a public repo on info/refs / upload-pack:

  1. Filter first. Candidates must have a fresh Cloudflare health result, a verified Ed25519 node identity, an owner-attested refs digest, the required operation, and must pass the repository integrity gate.
  2. Select and pin. Region/latency preference and a rotating cursor choose among eligible endpoints. A successful info/refs response records a short-lived node pin in clone_sticky.
  3. Bind and stream. The paired git-upload-pack request prefers that node, revalidates its repository proof, and carries a short-lived signed capability. If it fails, only another endpoint serving the identical approved refs state may be tried.

The mirror chosen at every step still passes the §3.2 integrity gate. The Worker issues a short-lived capability binding method, path, body digest, node, request id, and timestamp; the local gateway verifies it before serving refs whose hash matches the configured source-attested pin. Serving in place therefore does not weaken the tamper check.

Private repositories are excluded from named public routing and mirror enumeration. The owner-authorized private-replica route uses an opaque random identifier, returns only encrypted replica bytes, and has no repository-name lookup. Decryption keys stay in the owner/collaborator client. git-receive-pack never falls back to a socket.

6. Self-hosting a mainnode

A mainnode is the Cloudflare Worker in cloudflare_worker/. Standing up your own:

6.1 Deploy the relay

  1. Provision the Worker bindings — D1 for encrypted metadata and routing state, the room Durable Object for multiplayer interactions, and the configured edge-routing bindings. Repository bytes remain on independent encrypted mirror storage. wrangler.toml and migrations/ define the schema; migrate.sh applies it.
  2. Set the secrets the Worker needs (DATA_KEY / HMAC material for at-rest encryption, and any admin key). The catalog is encrypted at rest, so DATA_KEY must be stable across deploys.
  3. Deploy with cloudflare_worker/deploy.sh. It stamps a BUILD_REV var into the Worker and then verifies the live origin is actually serving it by polling GET /api/version until the reported rev matches (allowing for edge propagation). A deploy that never reports the new rev fails loudly instead of silently no-op'ing — this same /api/version check is your health probe for a self-hosted mainnode.

Your relay now answers the routes in §4 at your own host.

6.2 Point a client at it

The desktop client's mainnode host is fully configurable and threaded through everything downstream — you do not edit code:

6.3 Point the website at it

The relay serves its own static site (cloudflare_worker/public/). Its room WebSocket path is same-origin (location.host), so a self-hosted mainnode's site talks to itself with zero configuration. If you serve the static site separately from the relay, set window.FORKMESH_RELAY_HOST (e.g. "relay.example.com") before the chat scripts load and they will target that relay (cloudflare_worker/public/chat.js, cloudflare_worker/public/dashboard-chat.js).

6.4 Acceptance

A second party who has done the above has a working mainnode: a stock desktop client, given only the new host, registers an account, publishes a catalog record, registers a signed HTTPS mirror endpoint, and serves public clones through the two-request in-place HTTPS proxy — all against the new relay, with no forkmesh.com dependency.