Docs / Protocol spec
Developer docs
The ForkMesh mainnode protocol
ForkMesh is a peer-to-peer forge: desktop clients hold working copies, while managed mirror services hold independently served Git history and issue/PR/discussion records. 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.
The reference mainnode runs at app.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
app/; the reference client is the desktop app in
desktop/.
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: App & API Worker,
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 client starts with a long-lived Ed25519
identity key, generated on first run and stored as
ed25519.pem in the node's config directory
(desktop/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 gates private repositories and
owner-authorized changes. Managed mirror services, not desktop
clients, publish serving state to the catalog. A bare key can browse
and submit, while only an authorized account may administer
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
app/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.
| Purpose | Canonical string (fields after the prefix line, \n-joined) |
|---|---|
| Issue event | issue-event type · number · author · ts · sha256hex(content) |
| Discussion event | discussion-event type · number · author · ts · sha256hex(content) |
| Pull request | pull-event author · ts · sha256hex(title \0 base \0 head \0 patch [\0 commits]) |
| Pull comment | pull-comment type · number · author · ts · sha256hex(content) |
| Catalog record | catalog owner · repo · updatedAt |
| Catalog delete | catalog-delete owner · name · ts |
| Repo-state attestation | repostate owner · repo · stateHash · updatedAt |
| Release manifest | release repo · tag · author · ts · sha256hex(content) |
| Account reserve | reserve name · ts |
| Account finalize | finalize name · email · ts |
| Account key rotation | rotate oldPubkey · newPubkey · ts |
| Host token | host 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:
- Relay passphrase: the Town Square
mainnode/forkmeshcompatibility room uses lowercase hex ofSHA-256(DATA_KEY + ":room-chat-passphrase-v1"). Other repositories useSHA-256(DATA_KEY + ":room-chat-passphrase-v2:" + lower(owner + "/" + repo)).GET /api/chat/room-key?owner=&repo=releases the scoped value only to an authenticated account or signed node authorized for that repository; missing and unauthorized private repositories both return not found. - Room salt: the first 16 bytes of
SHA-256("ForkMesh room:" + <roomName>). - Client KDF: PBKDF2-HMAC-SHA256 over the UTF-8 passphrase, 210,000 rounds, producing a 256-bit AES-GCM key. The desktop, website, and Flutter clients use the same bytes.
- Participant-key option: clients that support an explicit passphrase can substitute one shared out of band. Only that mode keeps the key from the relay; the default relay-derived passphrase does not.
A desktop client 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 User-managed channels and webhooks
Active registered users 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 their creator, administrators, and explicitly selected active registered users. The creator can select 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 creator and platform administrators can manage membership and topics.
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.
A channel manager can create up to ten incoming webhooks for either
visibility. Creation returns a random 256-bit bearer URL once; only a
keyed blind index of its token is stored. A JSON delivery uses its
text, content, or message field,
is encrypted into the channel's current AES-GCM room format, broadcast
to live readers, and retained with normal room limits. Every delivery
also creates an account notification for the private channel's creator
and members, or for the active user directory when the channel is public.
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 Personal direct messages
Personal direct messages connect exactly two active registered users. Either participant can start the conversation, and starting the same pair again returns the existing conversation regardless of which user initiated it. Self-messaging and group conversations are rejected. Administrators have no implicit access: an account must be one of the two stored participants to list, open, or connect to the conversation.
Conversation and participant names are encrypted at rest. Blind
indexes enforce one conversation per unordered pair and authorize
participant lookups without exposing those names. The browser lists
its conversations through the cursor-paginated
GET /api/chat/direct-messages collection or starts one
with POST /api/chat/direct-messages and a
username field. New-pair creation is account-throttled,
while reopening an existing pair does not consume that budget.
Authenticated username lookup uses
GET /api/chat/direct-messages/users?query=…. It searches
all active registered users, including accounts excluded from the
public profile directory, because profile publication and direct
message eligibility are separate choices. Results are bounded and
empty queries never enumerate the account database.
A participant requests a non-cacheable passphrase and 60-second
WebSocket ticket through
GET /api/chat/direct-messages/<conversation-id>/room-access.
The socket at
/api/chat/direct-messages/<conversation-id>/ws?ticket=…
rechecks the active account and participant row before reaching the
Durable Object. Missing conversations and unauthorized accounts both
receive not found, including platform administrators.
Retained direct-message frames advance an opaque message counter.
Each participant has a server-side read cursor, exposed only as a
bounded unreadCount in their own conversation listing.
Opening room access or posting to
/api/chat/direct-messages/<conversation-id>/read
advances that participant's cursor without exposing plaintext.
Direct messages use the same relay-readable AES-GCM transport, encrypted attachment frames, and retained ciphertext limits as other chat rooms. The relay derives each conversation passphrase, so this is access-controlled encryption rather than end-to-end encryption against the relay operator.
2.4 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.5 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):
| Field | Meaning |
|---|---|
owner, name | logical repo identity within this mainnode |
visibility | public | private (anything but private is public) |
rootCommit | first/root commit — the network-wide group key that unites mirrors under different owners into one logical repo |
source | local-node (a working-copy holder / source of truth) or remote-clone (a mirror) |
sizeBytes, description, cloneUrl, solana, hostedSince, lastSync | descriptive |
commit, branch, counts, platform, version, nodeId, clonesServed, websiteServed | point-in-time node facts, shown even while the node is offline |
maintainer | the publishing key (base64url pubkey) |
stateHash, stateSig | the repo-state attestation (§3.2) |
The record is accepted only if:
catalogSigverifies forforkmesh-catalog-v1 \n owner \n name \n updatedAtagainst the account's registered key (maintainermust match).- If present,
stateSigverifies the attestation (§3.2) — a present but bad attestation rejects the whole write; absent is allowed for backward compatibility. updatedAtis 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:
stateHash= SHA-256 over the canonical heads+tags advertisement (advertised_refs_canonical).stateSigsignsforkmesh-repostate-v1 \n owner \n repo \n stateHash \n updatedAt.
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 source of truth is validated against its own attestations (current pin + recent history) — self-attestation is fine, the pin is a consistency check and the history absorbs the publish-to-serve lag.
- A mirror (
remote-clone) must serve a state some source in its logical-repo group (matched byrootCommit, name fallback) actually attested — a mirror's self-signed pin proves nothing, since a tampered mirror can always republish a hash for its forged refs. - If no source in the group has published an authenticated refs pin, direct HTTPS routing fails closed. A mirror's self-published digest is not enough to activate a public repository.
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
app/src/urls.py;
Default._route in entry.py dispatches
against it.
4.1 Rooms and direct-HTTPS routing
| Method · path | Purpose |
|---|---|
GET /api/room/<room>/ws | /clients | bare room WebSocket / live client count |
GET /api/repo/<owner>/<repo>/rooms/<room>/ws | /clients | per-repo room WS / count (the mainnode general room lives here) |
GET | POST /api/chat/channels | list authorized public and private channels or atomically create one with its private initial members |
GET | POST | DELETE /api/chat/channels/<id>/members | creator/administrator private membership list, invitation, and revocation |
GET | POST /api/chat/channels/<id>/webhooks | list webhook metadata or create a one-time incoming bearer URL as the channel creator or an administrator |
DELETE /api/chat/channels/<id>/webhooks/<webhook-id> | revoke an incoming channel webhook |
POST /api/chat/webhooks/<token> | post JSON into the channel and notify its audience without a user session |
GET /api/chat/channels/<id>/room-access | issue 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 |
GET | POST /api/chat/direct-messages | list participant conversations or idempotently start one with another active registered user |
GET /api/chat/direct-messages/<id>/room-access | issue conversation credentials only when the active account is one of the two participants |
GET /api/chat/direct-messages/<id>/ws?ticket=… | ticketed direct-message WebSocket with active-account and participant admission checks |
* /api/repo/<owner>/<repo>/host | retired repository socket transport — a cacheable 410 repository_socket_transport_retired, or 426 for an Upgrade; use the HTTPS routes below |
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.
| Path | Submission |
|---|---|
POST /api/repo/<owner>/<repo>/issues | signed issue open/comment/edit/status/… |
POST /api/repo/<owner>/<repo>/pulls | signed pull request |
POST /api/repo/<owner>/<repo>/pulls/<number>/merge | account-session-authorized asynchronous merge, pinned to one write-enabled mirror and exact base/head/metadata object ids |
POST /api/repo/<owner>/<repo>/discussions | signed discussion open/comment |
POST /api/repo/<owner>/<repo>/subscribe | signed thread subscribe/unsubscribe |
POST /api/repo/<owner>/<repo>/bounty | retired legacy bounty-custody path; returns a migration response and never creates a deposit wallet |
* /api/repo/<owner>/<repo>/shares | owner-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
| Path | Purpose |
|---|---|
POST /api/repo/<owner>/<repo>/mirrors | publish a signed catalog record (§3.1); GET reports mirror health |
* /api/repo/<owner>/<repo>/agents · /agents/list · /agents/<id>/prompt | signed 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/downloads | per-artifact download counts |
4.4 Git smart-HTTP (clone / fetch)
| Path | Purpose |
|---|---|
GET /<owner>/<repo>/info/refs | clone/fetch ref advertisement (request 1 of 2) |
POST /<owner>/<repo>/git-upload-pack | clone/fetch pack negotiation (request 2 of 2) |
POST /<owner>/<repo>/git-receive-pack | disabled 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
| Path | Purpose |
|---|---|
GET /api/accounts/<name> | public account lookup: availability, registered pubkey, profile flags |
GET /signup | canonical password-user signup page; issues the short-lived, one-use server claim required by its form |
POST /api/accounts/signup | page-internal form submission; rejects requests without the one-use /signup claim |
POST /api/accounts/reserve | desktop/headless node name reservation, always signed by its Ed25519 key |
POST /api/accounts/finalize | finish an already key-bound desktop/headless node registration |
POST /api/accounts/login | password/TOTP login; an optional pubkey registers the desktop device |
POST /api/accounts/rotate | replace the account's bound pubkey after the current bound key signs forkmesh-rotate-v1 |
POST /api/accounts/profile | profile updates such as payout address, email verification resend, rename, and delete |
POST /api/accounts/heartbeat | signed 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 · /leaderboards | cached homepage / network-page aggregates |
GET /api/status | 30-day per-system uptime for the public status page |
Password-user creation has one path: a browser first loads
/signup, which issues a short-lived signed HttpOnly claim
backed by one-use server state. The rendered form uses a per-claim
email field name and a hidden conventional-name honeypot. Its internal
API submission atomically consumes the claim; a direct or replayed
API call cannot create an account. Desktop/headless nodes use the
separate reservation protocol, but both reservation
(forkmesh-reserve-v1) and finalization
(forkmesh-finalize-v1) require the node's Ed25519 proof
and cannot create a password user.
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:
GET /<owner>/<repo>/info/refs— the ref advertisement.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:
- 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.
- Select and pin. Region/latency preference and a rotating cursor choose among eligible endpoints. A successful
info/refsresponse records a short-lived node pin inclone_sticky. - Bind and stream. The paired
git-upload-packrequest 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 app/.
Standing up your own:
6.1 Deploy the relay
- 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.tomlandmigrations/define the schema;migrate.shapplies it. - Set the secrets the Worker needs (
DATA_KEY/HMACmaterial for at-rest encryption, and any admin key). The catalog is encrypted at rest, soDATA_KEYmust be stable across deploys. - Deploy with
app/deploy.sh. It stamps build revision and target fingerprint vars into the Worker, then verifies the live App is actually serving them throughGET /api/version. A mismatch fails loudly instead of reporting a phantom deployment.
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:
- On the first-run screen, the Relay server field takes a bare host (e.g.
relay.example.com, orlocalhost:8787for a local dev relay). The client expands it to the full room URL withcanonicalServerUrl(desktop/src/MainWindowInternal.h) and persists it underserver/url.localhost/127.0.0.1getws://; everything else getswss://. - The mainnode path shape (
/api/repo/mainnode/forkmesh/rooms/general/ws) is a network-wide protocol constant (kMainnodeRoomPath), the same on every mainnode, so only the host varies. An advanced user can paste a fullwss://…URL to override the whole path if their deployment differs. - Relay consumers (room connect and catalog bootstrap) read the stored
server/url. Once mirror endpoints are known, the desktop verifies their signed health and reads configured SSH mirror remotes directly, so Mirrors and Pulls can continue through a relay outage.
6.3 Point the website at it
A self-hosted App includes its operational product UI from
app/public/. Its API and room WebSocket paths are
same-origin, so the site talks to its own mainnode with zero
configuration. The official deployment keeps marketing, docs, and
blog assets on the separate www Worker and serves the
product and API from app.forkmesh.com. If you serve a
browser client separately from the App, set
window.FORKMESH_RELAY_HOST (e.g.
"relay.example.com") before the chat scripts load and
they will target that relay (app/public/chat.js,
app/public/dashboard-chat.js).
An optional World deployment must set its APP_ORIGIN to
the self-hosted App, and the App's CORS_ALLOWED_ORIGINS
must include both origins.
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 and communicates with a separately deployed managed mirror service. That service publishes catalog state, registers its signed HTTPS endpoint, and serves public clones — all against the new relay, with no forkmesh.com dependency.