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.

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)
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 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):

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 app/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>/memberscreator/administrator private membership list, invitation, and revocation
GET | POST /api/chat/channels/<id>/webhookslist 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-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
GET | POST /api/chat/direct-messageslist participant conversations or idempotently start one with another active registered user
GET /api/chat/direct-messages/<id>/room-accessissue 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>/hostretired 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.

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>/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
GET /signupcanonical password-user signup page; issues the short-lived, one-use server claim required by its form
POST /api/accounts/signuppage-internal form submission; rejects requests without the one-use /signup claim
POST /api/accounts/reservedesktop/headless node name reservation, always signed by its Ed25519 key
POST /api/accounts/finalizefinish an already key-bound desktop/headless node registration
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

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:

  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 app/. 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 app/deploy.sh. It stamps build revision and target fingerprint vars into the Worker, then verifies the live App is actually serving them through GET /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:

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.