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.
| 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) |
| Commit comment | commit-comment sha · author · ts · sha256hex(body) |
| 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 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):
| 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
cloudflare_worker/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 | administrator-only private membership list, invitation, and revocation |
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 |
* /api/repo/<owner>/<repo>/host | legacy 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.
| 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>/commits | signed per-commit comment |
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 |
POST /api/accounts/signup | free web signup from node name, email, and password |
POST /api/accounts/reserve | name reservation, optionally signed by a desktop key |
POST /api/accounts/finalize | finish a reserved account with email/password and optional key binding |
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 |
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:
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 cloudflare_worker/.
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
cloudflare_worker/deploy.sh. It stamps aBUILD_REVvar into the Worker and then verifies the live origin is actually serving it by pollingGET /api/versionuntil the reportedrevmatches (allowing for edge propagation). A deploy that never reports the new rev fails loudly instead of silently no-op'ing — this same/api/versioncheck 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:
- 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(qt_client/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. - Every consumer (room connect, catalog publish, browse, clone) reads the stored
server/url, so setting the host once repoints the entire client.
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.