OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 28046a5

getBlob: verify relayed bytes, and KEEP them — a node that fetches a blob becomes a source
A keyless reader had exactly two routes to a blob: read the local overlay file, or ask the holder over RPC. Nothing kept what the relay returned, so every reader depended on the holder being online forever, for bytes that are content-addressed and therefore safe to copy anywhere. Measured today, all three CIDs against both nodes: bafkreidvjlvzswqoi2x… tabertron 200 stortron 404 bafkreifoyyngmpwgpn2u… tabertron 200 stortron 404 bafkreidyrm73udioa65… tabertron 404 stortron 404 (e2jev's avatar) The first two are IN the overlay and sitting on tabertron's disk. stortron could not get them — not because they are missing from the network, but because a reader had no way to keep a copy of anything it was ever handed. `fetchFile` appears nowhere in favela-web. Now: relay once while a holder is up, verify, write into the SHARED content- addressed dir hpp uses, and serve from disk forever after. The first request that needs a holder online is the last one. ★ VERIFY BEFORE SERVING, not just before storing. The relayed bytes were served straight through, so any peer answering `get-blob` could put arbitrary content under someone else's avatar CID. The CID *is* the hash, so checking is free and refusing is absolute. lib/blob-refs.js already had verifyBlobCid and said exactly this — it was being used on another path and not this one. Retention is best-effort and .part-then-rename: a failed write must never fail the response, and a truncated file under a content-addressed name would be served as fact forever. This does NOT close the whole gap. It makes any node that has ever seen a blob a permanent holder, which is what the reported symptom needs (ashatron offline ⇒ its images vanish). It does not let a node fetch by oid from a peer that has the bytes when no holder is up — that needs an overlay handle in favela-web and is the next step. And the third CID above stays 404 either way: pre-cutover blobs live in the node-local Hyperbee store that never replicates, which is a backfill, not this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
28046a5edd99eca4afe9d064ca608636faa54ba2
by plankatron <fleeky@gmail.com> · 2026-08-24T22:39:38.000Z

Changed files (2) +45 -3

bin/favela-web.js +44 -2
……@@ -10,7 +10,7 @@
1010import '../lib/bare-globals.js'
1111import http from 'bare-http1'
1212import bareFetch from 'bare-fetch'
13import { readFile } from 'bare-fs/promises'
13import { readFile, writeFile, mkdir, rename } from 'bare-fs/promises'
1414import fs from 'bare-fs'
1515import Hyperswarm from 'hyperswarm'
1616import Corestore from 'corestore'
……@@ -18,6 +18,7 @@ import { DidPeer, DidPeerRouter } from 'atproto-did-peer'
1818import { openCorestore } from 'atproto-store/lib/corestore.js'
1919import { OverlayRepoStore } from 'atproto-store/lib/overlay-repo-store.js'
2020import { roomKey16, blobPath, sniffMime, isSafeCid } from '../lib/blob-serve.js'
21import { verifyBlobCid } from '../lib/blob-refs.js'
2122import { parseHostedDids, createIsForeign, isIgnoredCollection, IGNORED_COLLECTIONS } from '../lib/hosted-dids.js'
2223import { join } from 'bare-path'
2324import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
……@@ -518,6 +519,27 @@ const delay = (ms) => new Promise((r) => setTimeout(r, ms))
518519// The dedup key includes the DID's front/RPC topology, not just the reason — a line keyed on the reason
519520// alone is emitted once and then indistinguishable from a live failure, which is the trap #14 recorded
520521// for _logFetchMiss. Keyed this way it re-emits the moment connectivity changes.
522// Keep a blob we had to fetch, so the next reader is served from disk and this node becomes a source.
523//
524// Writes into the SAME shared content-addressed dir hpp uses (blob-serve.js keeps that derivation in
525// one place), so a co-located hpp sees the file too rather than a second copy appearing beside it.
526//
527// .part then rename: a reader hitting the path mid-write must never get a truncated image, and a
528// truncated file under a content-addressed name would be served as fact forever. Same reason
529// favela-android stages its OTA bundle that way.
530//
531// The CALLER verifies the bytes against the cid first. This function must never be handed unverified
532// bytes — the filename is the hash, so writing the wrong body poisons the name permanently.
533async function retainBlob (did, cid, bytes) {
534 const dest = blobPath(OVERLAY_ROOT, did, cid)
535 const dir = dest.slice(0, dest.lastIndexOf('/'))
536 await mkdir(dir, { recursive: true })
537 const part = dest + '.part'
538 await writeFile(part, bytes)
539 await rename(part, dest)
540 console.log(JSON.stringify({ msg: 'blob retained from relay — this node can now serve it', did, cid: String(cid).slice(0, 20), bytes: bytes.length }))
541}
542
521543const _relayMiss = new Set()
522544function relayFronts (did) {
523545 try {
……@@ -1359,7 +1381,27 @@ const server = http.createServer(async (req, res) => {
13591381 // which never replicates, so waiting could never help. Fall back to asking the peer that HOLDS the
13601382 // blob, exactly as the keyless bridge does (peer-front.getBlob -> hpp's get-blob RPC). Works for
13611383 // both backends: the holder answers from its overlay files or its legacy store, whichever it has.
1362 if (!bytes) bytes = await relayBlob(bdid, bcid)
1384 if (!bytes) {
1385 const relayed = await relayBlob(bdid, bcid)
1386 // ⛔ VERIFY BEFORE SERVING, not just before storing. The CID *is* the hash, so a front that
1387 // returns the wrong body cannot pass — and this used to be served straight through, which made
1388 // any peer answering get-blob able to put arbitrary content under someone else's avatar.
1389 if (relayed && await verifyBlobCid(relayed, bcid)) {
1390 bytes = relayed
1391 // ★ RETAIN. Content-addressed bytes we already hold have no reason to be fetched twice, and a
1392 // node that keeps them becomes a SOURCE — which is the whole point of a content-addressed
1393 // store and the thing this surface was missing. Measured 2026-08-24: tabertron served two of
1394 // e2jev's blobs from disk while stortron 404'd the same CIDs, because a keyless reader had
1395 // exactly two routes — local file, or ask the holder — and no way to keep what it was given.
1396 // With this, the first request while a holder is up is the last one that needs it up.
1397 //
1398 // Best-effort: a failed write must never fail the response. We have the bytes; the reader
1399 // gets them either way.
1400 retainBlob(bdid, bcid, relayed).catch(() => {})
1401 } else if (relayed) {
1402 console.error(JSON.stringify({ msg: 'relayed blob failed content-address verification — refusing', did: bdid, cid: String(bcid).slice(0, 20) }))
1403 }
1404 }
13631405 if (!bytes) return json(res, 404, JSON.stringify({ error: 'BlobNotFound' }))
13641406 res.writeHead(200, { 'content-type': sniffMime(bytes), 'cache-control': 'public, max-age=31536000, immutable' })
13651407 return res.end(bytes)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.76",
3 "version": "0.3.77",
44 "description": "favela-web \u2014 the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",