OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-store commits / 7856e4c

0.0.17: ensureLocal must VERIFY a local hit, not merely find one
Content-addressing was used to FETCH but never to VERIFY. ensureLocal returned any local file it found without comparing it to the oid the caller asked for, so the pointer design's central promise was unenforced: the autobase converges { $oid, size }, but a node holding a SUPERSEDED copy resolved it happily, never re-fetched, and signed it as truth. Live on wj62: two co-owners, identical 139 converged pointers, both resolving all 139, different signed repos — differing on exactly one record, app.bsky.actor.profile/self, with two different avatar blob refs. The published profile flipped avatar on every signer change. pointerDiag (hyper-proto-peer 0.3.76) confirmed the pointer had converged and it was stortron's FILE that was stale, ruling out divergent autobase views. A local hit is now hashed and compared to the requested oid. Only a POSITIVE mismatch counts — bytes that cannot be hashed at all leave behaviour exactly as it was, so nothing churns where verification is impossible. On a mismatch the authoritative bytes are re-fetched, but persistent state is never destroyed to do it: _refetchOver fetches to a sibling temp path with reSeed:false, re-asserts the hash, renames into place atomically, then registers the FINAL path so the node serves the correct bytes afterwards. On any failure the existing file is left exactly as it was. When the authoritative bytes cannot be obtained, this returns null rather than the stale record. That is deliberate: returning the superseded copy is what published wrong content. Unresolved is precisely the state the forward-only sign gate handles ("pointer present, bytes unreplicated -> SKIP"), so a node that cannot prove its bytes match the group's pointer stops signing instead of signing content nobody agreed on. The trade is explicit — if NO peer holds the authoritative bytes the chain stalls rather than advancing wrongly, and it says so loudly. 6 new tests / 16 asserts cover replacement, the untouched matching path, the unverifiable path, preservation on failed fetch, rejection of bytes that do not hash to the requested oid, and the unchanged missing-bytes path. 59/59. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7856e4c8e67df94ac04e1b0584d085a540d7b40c
by plankatron <fleeky@gmail.com> · 2026-07-27T19:45:55.000Z

Changed files (3) +216 -2

lib/overlay-repo-store.js +72 -1
……@@ -148,7 +148,31 @@ export class OverlayRepoStore {
148148 // record or null. Local-hit is the common path; the fetch is the cross-node byte-resolution.
149149 async ensureLocal (collection, rkey, oid) {
150150 const local = await this.get(collection, rkey)
151 if (local != null) return local
151 if (local != null) {
152 // A local hit used to return here UNCONDITIONALLY, without ever comparing the bytes to the oid
153 // the caller asked for. That made the pointer design's central promise unenforced: the autobase
154 // converges { $oid, size }, but a node holding a SUPERSEDED copy resolved it happily, never
155 // re-fetched, and signed it as truth. Live on wj62 — two co-owners, identical 139 converged
156 // pointers, both resolving all 139, different signed repos, differing on exactly one record
157 // (app.bsky.actor.profile/self, two avatar blob refs) — so the published profile flipped avatar
158 // on every signer change. Content-addressing was being used to FETCH but never to VERIFY.
159 if (!oid || !this._overlay || typeof this._overlay.hashFile !== 'function') return local
160 const h = await this.localOid(collection, rkey)
161 // Only a POSITIVE mismatch is staleness. If the bytes cannot be hashed at all we cannot judge
162 // them, and inventing a verdict would re-resolve every record on every pass — behaviour must be
163 // unchanged wherever verification is impossible.
164 if (!h || h.oid === oid) return local // verified against the converged pointer — the common path
165 this._logStaleLocal(collection, rkey, oid, h ? h.oid : null)
166 const fetched = await this._refetchOver(collection, rkey, oid)
167 // Deliberately null, NOT the stale record, when the authoritative bytes cannot be obtained.
168 // Returning the superseded copy is what published wrong content: this record then reads as
169 // UNRESOLVED, which is precisely the state the forward-only sign gate is built to handle
170 // ("pointer present, bytes unreplicated -> behind -> SKIP"). A node that cannot prove its bytes
171 // match the group's pointer must not sign them. Note the trade: if NO peer holds the
172 // authoritative bytes, the record stays unresolved and the chain stalls rather than advancing
173 // with content nobody agreed on — loudly (see _logStaleLocal) instead of silently.
174 return fetched
175 }
152176 if (!this._overlay || !oid) return null
153177 const disk = this._pathFor(collection, rkey)
154178 try {
……@@ -159,6 +183,53 @@ export class OverlayRepoStore {
159183 return this.get(collection, rkey)
160184 }
161185
186 // Replace a SUPERSEDED local record file with the authoritative bytes for `oid`, without destroying
187 // what we hold until the replacement is verified. Fetches to a sibling temp path (reSeed:false so the
188 // overlay never indexes the temp), re-asserts the hash, then renames into place atomically and
189 // registers the FINAL path so this node serves the correct bytes afterwards. On any failure the
190 // existing file is left exactly as it was and null is returned. Returns the decoded record or null.
191 async _refetchOver (collection, rkey, oid) {
192 const disk = this._pathFor(collection, rkey)
193 const tmp = disk + '.incoming-' + String(oid).slice(0, 12)
194 try {
195 const r = await this._overlay.fetchFile(oid, { destPath: tmp, reSeed: false })
196 if (!r) { this._logFetchMiss(collection, rkey, oid, 'no peer served the authoritative bytes for a superseded local file'); return null }
197 // fetchFile verifies incrementally during transfer; re-assert before overwriting persistent state.
198 const h = await this._overlay.hashFile(tmp)
199 if (!h || h.contentHash !== oid) {
200 try { fs.unlinkSync(tmp) } catch {}
201 this._logFetchMiss(collection, rkey, oid, 'fetched bytes did not hash to the requested oid')
202 return null
203 }
204 fs.renameSync(tmp, disk)
205 try { await this._overlay.registerFile(this._overlayPath(collection, rkey), disk, { size: h.size }) } catch {}
206 try {
207 console.log(JSON.stringify({ msg: 'ensureLocal: replaced superseded record bytes with the authoritative copy', did: this._did, key: collection + '/' + rkey, oid: String(oid).slice(0, 16) }))
208 } catch {}
209 return this.get(collection, rkey)
210 } catch (err) {
211 try { fs.unlinkSync(tmp) } catch {}
212 this._logFetchMiss(collection, rkey, oid, (err && err.message) || String(err))
213 return null
214 }
215 }
216
217 // A local file that does not match the converged pointer is an ANOMALY, not routine lag — it means
218 // this node has been resolving (and possibly signing) content the group did not agree on. Reported
219 // once per key+oid so a persistent case is visible without spamming the resolve loop.
220 _logStaleLocal (collection, rkey, oid, localOid) {
221 if (!this._staleSeen) this._staleSeen = new Set()
222 const k = collection + '/' + rkey + ':' + oid
223 if (this._staleSeen.has(k)) return
224 this._staleSeen.add(k)
225 try {
226 console.error(JSON.stringify({
227 msg: 'ensureLocal: LOCAL BYTES DO NOT MATCH THE CONVERGED POINTER — this node held a superseded copy; re-fetching the authoritative bytes. Until it succeeds this record reads as unresolved and will not be signed.',
228 did: this._did, key: collection + '/' + rkey, pointerOid: String(oid).slice(0, 16), localOid: localOid ? String(localOid).slice(0, 16) : null
229 }))
230 } catch {}
231 }
232
162233 // Byte-resolution failures used to be swallowed entirely (`catch { return null }`), which made a
163234 // record whose bytes never replicated indistinguishable from any ordinary miss. That matters because
164235 // this path GATES SIGNING: the forward-only sign gate skips while a converged pointer's bytes are
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-store",
3 "version": "0.0.16",
3 "version": "0.0.17",
44 "description": "Holepunch-native atproto repo store — records in a classic Hyperbee (collection/rkey -> DAG-CBOR). The P2P home; the atproto-repo module is the bridge serializer over it.",
55 "type": "module",
66 "main": "lib/repo-store.js",
test/ensure-local-verify.test.js +143 -0
……@@ -0,0 +1,143 @@
1// ensureLocal must VERIFY a local hit against the converged pointer, not just find one.
2//
3// The pointer design converges { $oid, size } in the shared autobase and keeps the bytes as
4// content-addressed overlay files. ensureLocal returned any local file it found WITHOUT comparing it
5// to the requested oid, so content-addressing was used to FETCH but never to VERIFY. A node holding a
6// SUPERSEDED copy of a record resolved it happily, never re-fetched, and signed it as truth.
7//
8// Live on wj62 (2026-07-27): two co-owners, identical 139 converged pointers, both resolving all 139,
9// yet different signed repos — differing on exactly one record, app.bsky.actor.profile/self, with two
10// different avatar blob refs. The published profile therefore flipped avatar on every signer change.
11//
12// The repair must not destroy what we hold until the replacement is verified, so it fetches to a
13// sibling temp path, re-asserts the hash, then renames into place.
14//
15// Run: brittle-node test/ensure-local-verify.test.js
16import test from 'brittle'
17import fs from 'fs'
18import os from 'os'
19import path from 'path'
20import crypto from 'hypercore-crypto'
21import { OverlayRepoStore } from '../lib/overlay-repo-store.js'
22
23const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'els-'))
24const POST = 'app.bsky.feed.post'
25const PROFILE = 'app.bsky.actor.profile'
26
27// A content-addressed transport. `net` is the set of bytes any peer can serve.
28function mkOverlay (net, opts = {}) {
29 return {
30 registered: [],
31 async registerFile (overlayPath, diskPath, o = {}) {
32 const bytes = fs.readFileSync(diskPath)
33 const oid = crypto.data(bytes).toString('hex')
34 net.set(oid, bytes)
35 this.registered.push({ overlayPath, diskPath, oid })
36 return { contentHash: oid }
37 },
38 async fetchFile (oid, o = {}) {
39 if (opts.offline) return null
40 const bytes = net.get(oid)
41 if (!bytes) return null
42 fs.writeFileSync(o.destPath, opts.corrupt ? Buffer.concat([bytes, Buffer.from('x')]) : bytes)
43 if (o.reSeed !== false) this.registered.push({ reSeeded: o.destPath, oid })
44 return { destPath: o.destPath, local: false, size: bytes.length }
45 },
46 async hashFile (p) {
47 let bytes
48 try { bytes = fs.readFileSync(p) } catch { return null }
49 return { contentHash: crypto.data(bytes).toString('hex'), size: bytes.length }
50 }
51 }
52}
53
54const MINE = { $type: PROFILE, displayName: 'wj62', avatar: { ref: { $link: 'bafkreiAAA' }, size: 6533 } }
55const THEIRS = { $type: PROFILE, displayName: 'wj62', avatar: { ref: { $link: 'bafkreiBBB' }, size: 5962 } }
56
57// Author a record as the other peer would: seeds `net` and returns the converged pointer oid.
58async function authorElsewhere (net, collection, rkey, record) {
59 const s = await new OverlayRepoStore(tmp(), { overlay: mkOverlay(net) }).ready()
60 const { oid } = await s.put(collection, rkey, record)
61 return oid
62}
63
64test('a local hit that does NOT match the pointer is replaced with the authoritative bytes', async (t) => {
65 const net = new Map()
66 const root = tmp()
67 const ov = mkOverlay(net)
68 const store = await new OverlayRepoStore(root, { overlay: ov, did: 'did:plc:x' }).ready()
69
70 const { oid: mineOid } = await store.put(PROFILE, 'self', MINE) // what we hold
71 const theirsOid = await authorElsewhere(net, PROFILE, 'self', THEIRS) // what converged
72 t.not(mineOid, theirsOid, 'precondition: the pointer names different bytes than we hold')
73
74 const got = await store.ensureLocal(PROFILE, 'self', theirsOid)
75 t.alike(got, THEIRS, 'resolves to the record the group agreed on, not our superseded copy')
76
77 const after = await store.localOid(PROFILE, 'self')
78 t.is(after.oid, theirsOid, 'the file on disk now hashes to the converged pointer')
79 t.alike(await store.get(PROFILE, 'self'), THEIRS, 'and reads back as the authoritative record')
80 t.absent(fs.existsSync(path.join(root, 'records', PROFILE, 'self.dagcbor.incoming-' + theirsOid.slice(0, 12))), 'no temp file left behind')
81 t.ok(ov.registered.some((r) => r.diskPath && r.diskPath.endsWith('self.dagcbor') && r.oid === theirsOid), 're-registered the FINAL path so this node now serves the correct bytes')
82})
83
84test('a matching local hit is returned without any fetch (the common path is untouched)', async (t) => {
85 const net = new Map()
86 const ov = mkOverlay(net)
87 const store = await new OverlayRepoStore(tmp(), { overlay: ov, did: 'did:plc:x' }).ready()
88 const { oid } = await store.put(POST, '3kaaa', { $type: POST, text: 'hi', createdAt: '2026-07-27T00:00:00.000Z' })
89
90 const before = ov.registered.length
91 const got = await store.ensureLocal(POST, '3kaaa', oid)
92 t.alike(got, { $type: POST, text: 'hi', createdAt: '2026-07-27T00:00:00.000Z' }, 'returns the local record')
93 t.is(ov.registered.length, before, 'and did not fetch or re-register anything')
94})
95
96test('with no oid to check against, a local hit is returned unchanged (legacy / no-overlay)', async (t) => {
97 const net = new Map()
98 const store = await new OverlayRepoStore(tmp(), { overlay: mkOverlay(net), did: 'did:plc:x' }).ready()
99 await store.put(POST, '3kbbb', { $type: POST, text: 'legacy', createdAt: '2026-07-27T00:00:00.000Z' })
100 const got = await store.ensureLocal(POST, '3kbbb', null)
101 t.alike(got, { $type: POST, text: 'legacy', createdAt: '2026-07-27T00:00:00.000Z' }, 'unchanged behaviour when there is nothing to verify against')
102})
103
104test('if the authoritative bytes cannot be fetched, the STALE file is preserved and the read is unresolved', async (t) => {
105 const net = new Map()
106 const root = tmp()
107 const store = await new OverlayRepoStore(root, { overlay: mkOverlay(net, { offline: true }), did: 'did:plc:x' }).ready()
108
109 const { oid: mineOid } = await store.put(PROFILE, 'self', MINE)
110 const theirsOid = await authorElsewhere(new Map(), PROFILE, 'self', THEIRS) // nobody reachable serves it
111
112 const got = await store.ensureLocal(PROFILE, 'self', theirsOid)
113 t.is(got, null, 'returns null — a node that cannot prove its bytes match the pointer must not sign them')
114
115 const still = await store.localOid(PROFILE, 'self')
116 t.is(still.oid, mineOid, 'the superseded file is PRESERVED, not destroyed, on a failed repair')
117 t.absent(fs.existsSync(path.join(root, 'records', PROFILE, 'self.dagcbor.incoming-' + theirsOid.slice(0, 12))), 'and no temp file is left behind')
118})
119
120test('bytes that arrive not matching the requested oid are rejected and the local file is kept', async (t) => {
121 const net = new Map()
122 const root = tmp()
123 const store = await new OverlayRepoStore(root, { overlay: mkOverlay(net, { corrupt: true }), did: 'did:plc:x' }).ready()
124
125 const { oid: mineOid } = await store.put(PROFILE, 'self', MINE)
126 const theirsOid = await authorElsewhere(net, PROFILE, 'self', THEIRS)
127
128 const got = await store.ensureLocal(PROFILE, 'self', theirsOid)
129 t.is(got, null, 'mismatched bytes are refused')
130 const still = await store.localOid(PROFILE, 'self')
131 t.is(still.oid, mineOid, 'the existing file is untouched — persistent state is never replaced by unverified bytes')
132})
133
134test('a pointer with no local bytes still fetches normally (the unchanged missing-bytes path)', async (t) => {
135 const net = new Map()
136 const root = tmp()
137 const store = await new OverlayRepoStore(root, { overlay: mkOverlay(net), did: 'did:plc:x' }).ready()
138 const oid = await authorElsewhere(net, POST, '3kccc', { $type: POST, text: 'pulled', createdAt: '2026-07-27T00:00:00.000Z' })
139
140 const got = await store.ensureLocal(POST, '3kccc', oid)
141 t.alike(got, { $type: POST, text: 'pulled', createdAt: '2026-07-27T00:00:00.000Z' }, 'fetched into place')
142 t.is((await store.localOid(POST, '3kccc')).oid, oid, 'and hashes to the pointer')
143})