morphit/docs/IPFS-DISTRIBUTION-v1.9.3.md

356 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
1. A **permanent, public IPNS address** that always resolves to the **latest**
release (one stable `k51…` name; not an index of all releases).
2. **Pin the latest only**, not the whole history.
3. **Every Morphit instance hosts the latest** over its own Kubo node.
4. **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**: `pinByHash` on our canonical CID
returned `{"reason":"PAID_FEATURE_ONLY","details":"You must be on a paid plan to
pin by CID"}`. Its only free path is `pinFileToIPFS` — the directory-multipart
upload that **silently produces an empty 0-byte `bafkrei…` 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.network`
does 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.
1. **Compute the canonical CID with Kubo** (the `v0.42.0` we already pin, SHA-512
`054c38a0…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's `ipfs_cid`.
2. **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-hash` value; fail loud if not). That box is the origin + first
public host.
3. **w3name publishes the permanent IPNS name** (`MORPHIT_IPNS_KEY`) at that CID —
the `ipns://k51qzi5…` "always latest" pointer. Unchanged tooling; w3name's free
IPNS publishing is independent of any upload endpoint.
4. **Every instance pins from the network** — the existing
`ops/ipfs/morphit-ipfs-pin.sh` reads `/v1/release`, gets `ipfs_cid`, and
`ipfs pin add`s 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 1` of a test dir on the VPS
(`bafybeibebk6sxb…`) resolved on **`ipfs.io`** and **`dweb.link`** (independent
public gateways, no pinner). A self-hosted Kubo node IS publicly retrievable.
Cold content took a retry on `dweb.link` (504→200) — hence the guard rule below.
- **Permanent IPNS ✅** — `scripts/ipns-publish.mjs` published `k51qzi5…nra4c8`
`/ipfs/bafybeibebk6sxb…` via w3name; the w3name resolver
(`https://name.web3.storage/name/<k51>`) returned `"value":"/ipfs/bafybeibebk6sxb…"`
with validity `2027-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):** `distribution` accepts
`ipfs_cid` as CIDv1 (`b[a-z2-7]{58,110}``bafybei…` valid) + `ipns_name`
(`k51…`), both optional; `source_sha256`+`gpg_fingerprint` required together;
`mirrors` ≤ 8; block ≤ 4096 bytes. **No schema change needed.**
- **Payload builder (`release-build-payload.ts`):** already reads
`MORPHIT_BUILD_IPFS_CID` / `MORPHIT_BUILD_IPNS_NAME` and 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`):** Kubo `v0.42.0`, SHA-512
`054c38a0…d840d156`, `ipfs init --profile lowpower`, systemd `ipfs.service` +
`morphit-ipfs-pin.{service,timer}`. **This is exactly the tool CI + the seed use
for the CID.**
- **`/v1/release`** serves `distribution` (migration v53). **No change.**
- **IPNS tooling** (`scripts/ipns-keygen.mjs`, `scripts/ipns-publish.mjs` [w3name],
`apps/web/src/lib/ipns.ts` hardcoded `k51…` + 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 `source`s 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:
1. reads the current release + `ipfs_cid` from `/v1/release` (or takes the tag as
an arg pre-broadcast),
2. reconstructs the staged directory via **`ops/ipfs/stage-release-dir.sh <tag>
<dir>`** — the SAME script CI used, so the tree is byte-identical (this is what
makes the CIDs match),
3. `ipfs add -rQ --cid-version 1 "$STAGE"` → hosts + announces,
4. **asserts the resulting CID == the expected `ipfs_cid`** (fail loud on
mismatch — the determinism check in practice),
5. optionally `ipfs routing provide` to 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 `lowpower` profile limits
connections/DHT participation**; the seed may need a less-restricted profile or
`Routing.Type=dhtserver` + `Reprovider` tuned 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 (minuteshours); 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 `source`s 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-ops` wiring, the guard,
two smokes updated (`ipns-release-wiring-smoke` drop the bare-filename/Pinata
assertions; `ipfs-release-hosting-smoke`) + one new (`ipfs-selfseed-smoke`), and
operator docs (`OPERATIONS.md` + `RUN-A-MORPHIT-NODE.md` together;
`VERIFY-YOUR-DOWNLOAD.md`; `ops/ipfs/` notes).
---
## 9. Open questions to confirm at implementation (spikes — don't assume)
1. **Seed reachability:** after `morphit-ipfs-setup.sh` on the VPS, does
`ipfs add` content there resolve on `dweb.link` + `ipfs.io` within 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.*
2. **Determinism in practice:** `ipfs add -rQ --cid-version 1 --only-hash` (CI,
offline) vs `ipfs 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.)
3. **`--only-hash` in a fresh CI repo:** confirm it needs only `ipfs init` (no
daemon) and emits the root CID cleanly with `-rQ`.
4. **Guard gateway timing:** how long after the seed's `add` + `provide` does
`dweb.link`/`ipfs.io` serve 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)
1. **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.
2. **Determinism spike** (§9.2/9.3) — CI `--only-hash` == seed `add`.
3. **Delete `PINATA_JWT`** from Forgejo.
4. **`release.yml`** — remove Pinata step; add pinned-Kubo install + `--only-hash`
CID; decouple IPNS; keep anchor.
5. **`ops/ipfs/morphit-ipfs-seed.sh`** + `morphit-ops` wiring (§5.2) + the guard
script (§5.3).
6. **Smokes** — update `ipns-release-wiring-smoke` + `ipfs-release-hosting-smoke`;
add `ipfs-selfseed-smoke` (asserts: no commercial-pinner refs in release.yml;
Kubo `--only-hash` present; seed script asserts CID equality; guard present).
7. **Operator docs** (§8).
8. **Version bump** 1.9.2 → 1.9.3 (all 19 version-consistency touchpoints +
lockfile via `npm install --package-lock-only`, **never** `npm audit fix`) +
`RELEASE-NOTES-v1.9.3.md`.
9. **Full deep-deep** — 5 persona walkthroughs + full ~563-runner battery in
~3045-runner chunks (re-verify `vitest-must-pass` #204 + `workspace-typecheck`
#335 standalone) + static audit AL.
10. **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.sh` on `/opt/morphit` first so it *is* the seed.
---
## 11. Success criteria
- `curl https://morphit.io/v1/release` shows `ipfs_cid` **and** `ipns_name`.
- `https://dweb.link/ipfs/<cid>/metadata.json` **and** `https://ipfs.io/ipfs/<cid>/metadata.json` both return `"version":"1.9.3"` — publicly retrievable from our own seed, no company involved.
- `https://dweb.link/ipns/k51qzi5…/morphit-latest.tar.gz` downloads the v1.9.3 tarball.
- A second instance running `morphit-ipfs-setup.sh` pins the CID from the network within a timer tick or two (proves instance-to-instance hosting works).
- CI's `--only-hash` CID equals the seed's `add` CID (the anchor matches reality).
- `node scripts/verify-download.mjs` still 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.**