20 KiB
IPFS + IPNS distribution — v1.9.3 design (self-hosted seed, no commercial pinners)
Status: design / not yet implemented. Rewritten after the provider spikes (2026-07-27) proved that no commercial pinning service works for us on a free tier. This is the plan to build against — deliberately, when rested. v1.9.2 is live on the mirror + GPG-signature + on-chain-SHA-256 path, so there is no deadline and no dead pointer anywhere on-chain.
Priorities (unchanged): privacy #1, decentralization #2, grandma-UX #3, tiny footprint #4. Hard constraint from Ken: no paid services, ever — no Pinata, Storacha/fil.one, or Lighthouse plans, no crypto, no accounts we depend on. This design honours that: the only host is infrastructure we already run.
0. What we want
- A permanent, public IPNS address that always resolves to the latest
release (one stable
k51…name; not an index of all releases). - Pin the latest only, not the whole history.
- Every Morphit instance hosts the latest over its own Kubo node.
- Zero ongoing cost, no third-party accounts. ← added after the spikes.
1. Spike findings — why all three commercial pinners are OUT
Ran throwaway tests (2026-07-27) before writing any code. Results:
- Pinata — free plan blocks pin-by-CID:
pinByHashon our canonical CID returned{"reason":"PAID_FEATURE_ONLY","details":"You must be on a paid plan to pin by CID"}. Its only free path ispinFileToIPFS— the directory-multipart upload that silently produces an empty 0-bytebafkrei…object on our (newer, Files/x402-era) account, which is what broke the v1.9.2 attempt in the first place. Both free paths are dead ends. Out. - Lighthouse — dashboard shows "14-day free trial" + 5 GB, i.e. a trial, not a permanent free tier. Even if uploads worked, it becomes a paywall in two weeks. Out. (Also GitHub-OAuth signup, no wallet — but that doesn't change the trial limit.)
- Storacha / fil.one — mid-rebrand; the upload host
up.storacha.networkdoes not resolve for us (only host failing out of six checked — DNS is healthy, the endpoint is simply down/moving). Free tier is also "min 30 days retention," not indefinite. Unusable now and uncertain later. Out.
Conclusion: the "replicate across several pinning services" idea does not survive free-tier reality. That's fine — see §2. The spikes did exactly their job: killed a bad assumption before it reached the release pipeline.
1.1 v1.9.2 pin post-mortem (kept for the record)
release.yml's pin step used Pinata pinFileToIPFS with bare-named multipart
files (the "assemble a directory" trick). On our account that returns an empty
bafkrei… (NumberOfFiles:1, PinSize:0) — no usable CID → the IPNS step logged
No ipfs-cid.txt … skipping → v1.9.2 shipped with no ipfs_cid/ipns_name (both
schema-optional). PINATA_JWT also held a legacy aNh… key (not a JWT) — fixed
mid-session, now moot since Pinata is dropped. MORPHIT_IPNS_KEY validated GOOD
(real w3name key → the k51qzi5…nra4c8 name in apps/web/src/lib/ipns.ts).
2. The decision: self-seed with our own Kubo
We don't need a pinning company. An IPFS CID is deterministic, so we never have to "upload" anywhere to get one — we compute it, and we host it on infrastructure we already run.
- Compute the canonical CID with Kubo (the
v0.42.0we already pin, SHA-512054c38a0…d840d156).ipfs add -r --cid-version 1 --only-hash <staged-dir>yields the CID offline — no daemon, no network, no account, deterministic. CI does this to fill the on-chain anchor'sipfs_cid. - Host it on our own seed node — Ken's release VPS (a Morphit instance
running Kubo)
ipfs add -r --cid-version 1 <same staged-dir>to actually store- announce it. Same tool + version + files ⇒ identical CID (verified equal
to CI's
--only-hashvalue; fail loud if not). That box is the origin + first public host.
- announce it. Same tool + version + files ⇒ identical CID (verified equal
to CI's
- w3name publishes the permanent IPNS name (
MORPHIT_IPNS_KEY) at that CID — theipns://k51qzi5…"always latest" pointer. Unchanged tooling; w3name's free IPNS publishing is independent of any upload endpoint. - Every instance pins from the network — the existing
ops/ipfs/morphit-ipfs-pin.shreads/v1/release, getsipfs_cid, andipfs pin adds it (fetching from the seed + other instances). Unchanged.
Net: permanent public IPNS + latest-pinned + real decentralization, at zero cost, depending on no company. The load-bearing hosts are the Morphit instances themselves (grows with the network) + the seed; the git mirrors + on-chain SHA-256 + GPG signature remain the authenticity anchors.
Why this is actually better than the pinner plan: the pinning services were only ever a convenience seed (a nice always-up gateway URL). Instance-Kubo hosting was always the real decentralization. Removing the companies removes a dependency and a cost, and matches the project's ethos.
2.1 Proven end-to-end (spikes, cp573) — the model is de-risked
Both halves confirmed live, zero paid services:
- Self-seed hosting ✅ —
ipfs add -rQ --cid-version 1of a test dir on the VPS (bafybeibebk6sxb…) resolved onipfs.ioanddweb.link(independent public gateways, no pinner). A self-hosted Kubo node IS publicly retrievable. Cold content took a retry ondweb.link(504→200) — hence the guard rule below. - Permanent IPNS ✅ —
scripts/ipns-publish.mjspublishedk51qzi5…nra4c8→/ipfs/bafybeibebk6sxb…via w3name; the w3name resolver (https://name.web3.storage/name/<k51>) returned"value":"/ipfs/bafybeibebk6sxb…"with validity2027-07-27(the ~1-yr record). Name→CID resolves.
2.2 DECISION — IPNS stays on w3name (not self-hosted DHT IPNS)
Considered publishing the IPNS record from our own Kubo (ipfs name publish, DHT,
no Storacha). Rejected for v1.9.3. DHT IPNS records live hours and need the
node constantly re-publishing; resolution is slow/flaky. w3name records live ~1 yr
(re-published every release automatically) and resolve reliably through
w3name-aware gateways. The w3name dependency is soft: it's only the "which CID
is latest" pointer — the content resolves by CID on any gateway regardless, and
releases stay fully verifiable/fetchable via git mirrors + on-chain SHA-256 + GPG
even if w3name vanished (you'd just re-publish the pointer elsewhere). Acceptable
failure mode for a free convenience layer; least-fragile way to get a genuinely
permanent name. (Ken's call, delegated 2026-07-27.) Note: w3name resolution is via
name.web3.storage / w3s.link / dweb.link, not native ipfs.io/ipns/
(which does DHT-only resolution and 500s on w3name-hosted names — expected).
2.3 Guard rule refinement (from the spike)
The reachability guard (§5.3) passes on the first independent public gateway that
serves the CID, with backoff — it must NOT require all gateways, because a
healthy node routinely has one gateway serve instantly while another 504s on cold
content (observed above). Poll a couple (ipfs.io, dweb.link), pass on first
success.
3. Current-tree contract (mostly unchanged)
- Schema (
packages/release-schema+ indexer handler):distributionacceptsipfs_cidas CIDv1 (b[a-z2-7]{58,110}—bafybei…valid) +ipns_name(k51…), both optional;source_sha256+gpg_fingerprintrequired together;mirrors≤ 8; block ≤ 4096 bytes. No schema change needed. - Payload builder (
release-build-payload.ts): already readsMORPHIT_BUILD_IPFS_CID/MORPHIT_BUILD_IPNS_NAMEand emits the block. No builder change needed. - Instance pin (
ops/ipfs/morphit-ipfs-pin.sh): already does daemon-check → already-pinned? →ipfs pin add <cid>(fetch from network) → best-effort drop of stale older pins. Fetch-from-network is unchanged. The only new sibling it needs is a seed path (add local files) for the origin — see §5.2. - Kubo pin (
ops/ipfs/morphit-ipfs-setup.sh): Kubov0.42.0, SHA-512054c38a0…d840d156,ipfs init --profile lowpower, systemdipfs.service+morphit-ipfs-pin.{service,timer}. This is exactly the tool CI + the seed use for the CID. /v1/releaseservesdistribution(migration v53). No change.- IPNS tooling (
scripts/ipns-keygen.mjs,scripts/ipns-publish.mjs[w3name],apps/web/src/lib/ipns.tshardcodedk51…+ helpers). Unchanged.
Everything the redesign touches is in release.yml, one new seed script + its
morphit-ops wiring, two smokes (update) + one new, and operator docs.
4. Determinism — the one thing the whole design rests on
ipfs add -r --cid-version 1 with Kubo defaults (chunker size-262144 = 256 KiB,
raw-leaves=true for CIDv1, balanced layout, sha2-256) is deterministic: same
bytes + same filenames + same Kubo version ⇒ same CID. --only-hash builds the
identical DAG without storing/announcing, so CI's --only-hash CID == the seed
node's add CID as long as both use Kubo v0.42.0 and the byte-identical
staged directory (same files, same names). We pin both. As belt-and-suspenders,
the seed step asserts its add CID equals the anchored CID and fails loud on
mismatch (guards against a future Kubo default change).
Staged directory (identical in CI and on the seed), bare names at root so
ipns://<name>/<file> resolves directly:
morphit-v<ver>.tar.gz morphit-latest.tar.gz morphit-v<ver>.tar.gz.sha256
morphit-v<ver>.tar.gz.asc RELEASE-NOTES.md RELEASE-NOTES-v<ver>.md metadata.json
⚠ The metadata-determinism trap (found + fixed in build): IPFS hashes content +
filenames, not mtimes/modes — so file timestamps don't affect the CID. But a
value inside a file does. The old Pinata-era metadata.json embedded a live
released_utc timestamp; CI and the seed would each generate a different one →
different metadata.json bytes → different CID → the guard/assert rejects every
release. Fix: metadata.json now carries only tag-derived, fixed values (name,
version, tag, tarball, sha256, repository, release_url, verify_guide) with a
fixed key order — no timestamp, host, or random. Implemented as one shared
script, ops/ipfs/stage-release-dir.sh <tag> <out-dir>, called by BOTH CI and the
seed, so the staging can never drift between them. It also verifies the fetched
tarball against its published .sha256 before staging. (Written cp572; POSIX,
deterministic, no secrets.)
5. What changes
5.1 release.yml — replace the Pinata step with a deterministic CID + IPNS
Remove the "Pin release directory to IPFS (Pinata)" step entirely. In its place:
Install pinned Kubo (ephemeral, in the runner). Reuse the exact version +
SHA-512 from ops/ipfs/morphit-ipfs-setup.sh (v0.42.0 / 054c38a0…). Verify the
checksum before use (same as the instance setup does).
Compute the canonical CID (offline, deterministic). Stage via the shared script (§4), then hash it with the pinned Kubo — no daemon, no network:
STAGE="$(mktemp -d)/morphit"; mkdir -p "$STAGE"
sh ops/ipfs/stage-release-dir.sh "$TAG" "$STAGE" # single source of truth
export IPFS_PATH="$(mktemp -d)"; ipfs init --profile lowpower >/dev/null
CID="$(ipfs add -rQ --cid-version 1 --only-hash "$STAGE")" # -Q = root CID only
echo "$CID" > ipfs-cid.txt
No upload, no secret, no account.
Publish IPNS (unchanged step, now decoupled from any pinning). Run
scripts/ipns-publish.mjs with RELEASE_CID=$CID + MORPHIT_IPNS_KEY →
ipns-name.txt. (The old "no ipfs-cid.txt → skip" coupling is gone because the CID
is always computed now.)
Write the anchor (existing step, unchanged logic): source_sha256 +
gpg_fingerprint always; MORPHIT_BUILD_IPFS_CID from ipfs-cid.txt;
MORPHIT_BUILD_IPNS_NAME from ipns-name.txt. Attach distribution-anchor.env.
Net effect on the ELI5 ceremony: Block 4 is unchanged — it sources the anchor
and the payload now carries ipfs_cid + ipns_name automatically. No Pinata, no
manual overrides.
5.2 New: ops/ipfs/morphit-ipfs-seed.sh — make the release box the origin host
A small POSIX script (sibling to morphit-ipfs-pin.sh) the seed runs. It:
- reads the current release +
ipfs_cidfrom/v1/release(or takes the tag as an arg pre-broadcast), - reconstructs the staged directory via **`ops/ipfs/stage-release-dir.sh `** — the SAME script CI used, so the tree is byte-identical (this is what makes the CIDs match),
ipfs add -rQ --cid-version 1 "$STAGE"→ hosts + announces,- asserts the resulting CID == the expected
ipfs_cid(fail loud on mismatch — the determinism check in practice), - optionally
ipfs routing provideto push the provider record promptly.
ops/ipfs/stage-release-dir.sh already exists (cp572, syntax-verified). What
remains for §5.2 is morphit-ipfs-seed.sh (the add + assert + provide wrapper)
plus its morphit-ops wiring.
5.3 The guard — never anchor/broadcast a CID the public can't fetch
Before Block 5 (broadcast), verify the canonical CID actually resolves on
independent public gateways (https://dweb.link/ipfs/<CID>/metadata.json and
https://ipfs.io/ipfs/<CID>/metadata.json, expecting "version":"<ver>"), with a
few-minute backoff budget. This proves the seed is publicly reachable (see §6). If
it never resolves → do not broadcast; fix the seed's reachability first. (This
is the check that would have stopped tonight's dead Qmb11j…/empty-bafkr….)
Implement as a step/script run between seeding and broadcast.
6. The real tradeoff of self-hosting: the seed must be publicly reachable
A commercial pinner was always dialable; our own node might not be. For public gateways and other instances to fetch the CID, the seed's Kubo must be reachable on the DHT — i.e. TCP/UDP 4001 reachable from the internet (public IP or port forward), and it must announce (provide) its content. Considerations:
- Ken's VPS has a public IP — likely fine, but the
lowpowerprofile limits connections/DHT participation; the seed may need a less-restricted profile orRouting.Type=dhtserver+Reprovidertuned so it announces reliably. - The guard (§5.3) is the safety net: if the seed isn't reachable, the CID won't resolve on public gateways and we won't broadcast. So a mis-networked seed fails loudly before the chain, never silently after.
- Cold-start / single-seed availability: if only the seed hosts the CID and it goes down before any instance has pinned, the release is unreachable over IPFS (mirrors + on-chain hash still fine). Mitigations: the seed stays up long enough for instances' timers to pin (minutes–hours); Ken can run the seed on >1 box; the guard confirms reachability at broadcast time. Document this as an operational expectation, not a flaw — it's the price of owning the infra.
Open item to verify (spike, §9): confirm the VPS's Kubo, once set up, is
actually dialable and its CID resolves on dweb.link/ipfs.io. If a home/NAT box
can't be made reachable, the seed must be a public-IP host (the VPS) — which it is.
7. Secrets
| secret | keep? | why |
|---|---|---|
MORPHIT_IPNS_KEY |
KEEP | w3name IPNS publish still used |
PINATA_JWT |
DELETE | Pinata dropped; nothing references it after §5.1 |
| Storacha / Lighthouse keys | n/a | never added; not needed |
No new secrets. The CID computation + seeding use only the pinned Kubo binary — no credentials at all.
8. What does NOT change
Schema / validator / indexer handler; release-build-payload.ts; Block 4 / the
ELI5 ceremony (still sources the anchor); ops/ipfs/morphit-ipfs-pin.sh fetch
model; /v1/release; ipns-publish.mjs + ipns.ts + the k51… name; the Kubo
v0.42.0 pin. Contained changes only: release.yml (pin step → Kubo --only-hash
- decoupled IPNS), new
morphit-ipfs-seed.sh+morphit-opswiring, the guard, two smokes updated (ipns-release-wiring-smokedrop the bare-filename/Pinata assertions;ipfs-release-hosting-smoke) + one new (ipfs-selfseed-smoke), and operator docs (OPERATIONS.md+RUN-A-MORPHIT-NODE.mdtogether;VERIFY-YOUR-DOWNLOAD.md;ops/ipfs/notes).
9. Open questions to confirm at implementation (spikes — don't assume)
- Seed reachability: after
morphit-ipfs-setup.shon the VPS, doesipfs addcontent there resolve ondweb.link+ipfs.iowithin a couple minutes? (Tests dialability + announce.) If not, tune the profile/reprovider (§6). This is the new go/no-go gate — the pinner spikes are replaced by this one. - Determinism in practice:
ipfs add -rQ --cid-version 1 --only-hash(CI, offline) vsipfs add -rQ --cid-version 1(seed, real) on the same staged dir with Kubo v0.42.0 — confirm identical CID. (Expected yes; the seed step asserts it regardless.) --only-hashin a fresh CI repo: confirm it needs onlyipfs init(no daemon) and emits the root CID cleanly with-rQ.- Guard gateway timing: how long after the seed's
add+providedoesdweb.link/ipfs.ioserve it? Sets the guard's backoff budget.
Each is a small throwaway test on the VPS + a CI scratch run, done before wiring the release path.
10. Implementation order (when rested)
- Seed-reachability spike (§9.1) on the VPS — the gate. If a self-hosted Kubo node can't be made publicly retrievable, the whole model needs rethinking, so prove it first.
- Determinism spike (§9.2/9.3) — CI
--only-hash== seedadd. - Delete
PINATA_JWTfrom Forgejo. release.yml— remove Pinata step; add pinned-Kubo install +--only-hashCID; decouple IPNS; keep anchor.ops/ipfs/morphit-ipfs-seed.sh+morphit-opswiring (§5.2) + the guard script (§5.3).- Smokes — update
ipns-release-wiring-smoke+ipfs-release-hosting-smoke; addipfs-selfseed-smoke(asserts: no commercial-pinner refs in release.yml; Kubo--only-hashpresent; seed script asserts CID equality; guard present). - Operator docs (§8).
- Version bump 1.9.2 → 1.9.3 (all 19 version-consistency touchpoints +
lockfile via
npm install --package-lock-only, nevernpm audit fix) +RELEASE-NOTES-v1.9.3.md. - Full deep-deep — 5 persona walkthroughs + full ~563-runner battery in
~30–45-runner chunks (re-verify
vitest-must-pass#204 +workspace-typecheck#335 standalone) + static audit A–L. - Ship via the 6 ELI5 blocks — the release now: seeds the CID from Ken's box,
publishes IPNS, anchors both; instances pick it up. Ken runs the one-time
morphit-ipfs-setup.shon/opt/morphitfirst so it is the seed.
11. Success criteria
curl https://morphit.io/v1/releaseshowsipfs_cidandipns_name.https://dweb.link/ipfs/<cid>/metadata.jsonandhttps://ipfs.io/ipfs/<cid>/metadata.jsonboth return"version":"1.9.3"— publicly retrievable from our own seed, no company involved.https://dweb.link/ipns/k51qzi5…/morphit-latest.tar.gzdownloads the v1.9.3 tarball.- A second instance running
morphit-ipfs-setup.shpins the CID from the network within a timer tick or two (proves instance-to-instance hosting works). - CI's
--only-hashCID equals the seed'saddCID (the anchor matches reality). node scripts/verify-download.mjsstill verifies against the on-chain SHA-256 + GPG (IPFS is additive, never the sole anchor).- The guard provably refuses to broadcast a CID that doesn't resolve (tested with a bogus CID).
- Recurring cost: $0. Third-party accounts required: none.