OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 54902f9

the returned blob store must be the one the node actually has
⛔ `blobStore` was assigned ONLY inside `if (storeBackend === 'overlay' && repoRoot && recordOverlay)`, and the node returns `blobs: blobStore`. Every other consumer — serveDevice, serveMembrane, the UDS surface — takes `blobs` from the enclosing scope, where `legacyBlobs` is always a real, working store with the same drop-in put/get/has API. So for any DID WITHOUT the overlay backend the returned object said there was no blob store while one sat right there, already serving three other surfaces. favela-host mints DIDs and sets no storeBackend, so that is every DID it has ever made. ⚠ IT ONLY BITES AN IN-PROCESS CONSUMER, which is why it survived: the returned field exists FOR the in-process case — the note beside the return names favela-android — and that is the single case it got wrong. Measured on a handset: 11 posts published to its atproto repo and NOT ONE carried an image, while the same posts reached the fediverse with their jpegs attached, because that projection never asks this object for a blob store. `upload-blob` threw 'this node has no blob store', BskySink caught it and returned null, and each post went out as text with a loss line nobody reads. The images were never missing; nothing could reach them. The assignment now happens after the branch, unconditionally. The test is source-level and says why: observing this otherwise means booting a swarm device with a real corestore and overlay to assert one field of a returned object, and the defect is an assignment in the wrong scope — exactly what reading the source pins. It fails against the previous lib. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
54902f9517d3f68a0148655d9b41ebe613ff414a
by plankatron <fleeky@gmail.com> · 2026-09-11T23:06:53.000Z

Changed files (3) +64 -1

lib/device-node.js +15 -1
……@@ -553,9 +553,23 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe
553553 // autobase/bytes-in-the-overlay design PointerSubstrate already uses for records. Reserved "__"
554554 // key: splitKey rejects it, so it can never reach a signed commit.
555555 blobs = new DualBlobStore(new BlobPointerStore(sb, overlayBlobs, { did }), legacyBlobs)
556 blobStore = blobs // expose to the reconcile runner (see the hoisted declaration above)
557556 console.log(JSON.stringify({ msg: 'blob store: overlay content-addressed files + shared pointers + legacy dual-read', did, path: join(repoRoot, 'blobs') }))
558557 }
558 // ⛔ ALWAYS, NOT ONLY ON THE OVERLAY PATH. This assignment lived inside the `if` above, so a DID
559 // with no `storeBackend: 'overlay'` — which is every DID minted by favela-host, since it sets none
560 // — returned `blobs: null` from bootMultiDevice while `legacyBlobs` was right here, working, and
561 // already handed to serveDevice and serveMembrane. The two stores share a drop-in put/get/has API
562 // by design, so there was never a reason for the handle to report one and not the other.
563 //
564 // ⚠ IT ONLY BITES AN IN-PROCESS CONSUMER, which is why it survived: every socket surface takes
565 // `blobs` directly from this scope. The returned field exists precisely FOR the in-process case
566 // (see the note where this node is returned), and that is the one case it got wrong.
567 //
568 // Measured: a handset published 11 posts to its atproto repo and NONE of them carried an image.
569 // The same posts reached the fediverse with their jpegs attached, because that projection never
570 // asks this object for a blob store. upload-blob threw 'this node has no blob store', BskySink
571 // caught it and returned null, and each post went as text with a loss line nobody reads.
572 blobStore = blobs
559573 // LOCAL UDS control surface — bring it up EARLY (before serveDevice/serveMembrane, whose sync/ready can
560574 // stall on an un-converged base's base.update), so a co-located client (favela-web) can ALWAYS reach the
561575 // device over local IPC even while convergence lags. Owner-gated by the filesystem → no writer-allowlist.
test/all.js +1 -0
……@@ -9,6 +9,7 @@ import './conflict-watch.test.js' // fast + offline; drives a REAL conflict —
99import './side-store-channel-priority.test.js' // fast + offline
1010import './shared-store-autoopen.test.js' // fast + offline; corestore auto-opens on-disk cores on remote interest (#22)
1111import './inline-migration-boot.test.mjs' // fast + offline
12import './blob-store-exposed.test.mjs' // fast + offline; the returned handle must report the store it has
1213import './mint.test.mjs' // fast + offline; creating an identity on the device that will use it
1314import './hosting-cli.test.mjs' // fast + offline; the invite ceremony's terminal half
1415import './swarm-device.test.js'
test/blob-store-exposed.test.mjs +48 -0
……@@ -0,0 +1,48 @@
1// The blob store a booted node REPORTS must be the one it actually has.
2//
3// ⛔ THE DEFECT. `blobStore` is hoisted (the reconcile runner needs it) and was assigned ONLY inside
4// `if (storeBackend === 'overlay' && repoRoot && recordOverlay)`. Every other consumer takes `blobs`
5// straight from that scope, so serveDevice, serveMembrane and the UDS surface all had a working store
6// — while the object bootSwarmDevice RETURNS carried `blobs: null` for any DID without the overlay
7// backend. favela-host mints DIDs and sets no storeBackend, so that is every DID it has ever made.
8//
9// ⚠ AND IT ONLY BITES AN IN-PROCESS CONSUMER, which is exactly why it survived: the returned field
10// exists FOR the in-process case (the note beside the return says so, naming favela-android), and that
11// is the single case it got wrong. Every socket path reads the other variable.
12//
13// Measured on a handset: 11 posts published to its atproto repo, NONE carrying an image, while the
14// same posts reached the fediverse with their jpegs attached — that projection never asks this object
15// for a blob store. `upload-blob` threw 'this node has no blob store', BskySink caught it and returned
16// null, and each post went out as text with a loss line nobody reads.
17//
18// ⚠ SOURCE-LEVEL, and deliberately. Observing it otherwise means booting a swarm device with a real
19// corestore and overlay — minutes, and network-shaped — to assert one field of the returned object.
20// The defect is an ASSIGNMENT IN THE WRONG SCOPE, which is exactly what reading the source can pin.
21
22import test from 'brittle'
23import fs from 'bare-fs'
24
25const SRC = new URL('../lib/device-node.js', import.meta.url).pathname
26
27test('the returned blob store is assigned outside the overlay branch', (t) => {
28 const src = fs.readFileSync(SRC, 'utf8')
29
30 t.absent(/blobStore = blobs \/\/ expose to the reconcile runner/.test(src),
31 'the assignment no longer sits inside the overlay-only branch')
32
33 // It must be reached whether or not the branch above ran. The branch closes with `}` on its own
34 // line; the assignment has to come after it, at the outer indentation.
35 t.ok(/\n blobStore = blobs\n/.test(src),
36 'blobStore is assigned at the same level as the `if`, so a legacy DID reports its store too')
37
38 // And the two stores are interchangeable by design — the comment in device-node.js says "Same
39 // drop-in put/get/has API either way" — so reporting the legacy one is correct, not a downgrade.
40 t.ok(/let blobs = legacyBlobs/.test(src), 'legacy is the default store, and it is a real one')
41})
42
43test('nothing else re-narrows it afterwards', (t) => {
44 const src = fs.readFileSync(SRC, 'utf8')
45 const assignments = (src.match(/^\s*blobStore = /gm) || [])
46 t.is(assignments.length, 1, 'exactly one assignment, so there is no second path that can null it')
47 t.ok(/blobs: blobStore/.test(src), 'and it is still what the node hands back')
48})