OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-did-peer commits / fe7b86b

0.2.11 (org #34): getRepo({ since }) — serve a real partial CAR from the replicated firehose
pds/lib/server.js called front.getRepo() with no arguments, so `since` never reached the peer and the identical full CAR came back byte for byte. The trap is not the wasted bytes: a caller passing `since` got correct results BY ACCIDENT and would have been silently wrong the day partial CARs landed. This is that day. NO NEW TRANSPORT, NO hpp CHANGE. _openCores opens the firehose in BOTH modes, so every front already holds the frames, and each #commit frame already carries the blocks that were new at that commit. The diff is the union over frames newer than `since` — assembled locally, no RPC to the peer, no new storage. THE RESULT ALWAYS DECLARES ITSELF. `partial` and `sinceHonoured` are set explicitly on every path, and an unanswerable `since` falls back to the full CAR carrying a `reason`. A caller must never have to infer from the bytes which one it got — inferring is exactly what accept-and-ignore forced on everyone. `sinceHonoured` is null (not false) when nothing was asked, so "not requested" and "requested and refused" cannot be confused. THE HEAD BLOCK IS LOCATED, NEVER SYNTHESISED. The obvious implementation hands dagCbor.encode(head.signed) to the assembler — which would also mean serving a CAR rooted at a commit the firehose does not carry, unwalkable even for a caller holding every other block. So repo-diff LOCATES it in the frames and refuses when it cannot. Stricter, and it keeps a dag-cbor dependency out of this package, which has none and is a symlinked sibling (adding one is a deploy hazard here). Test: test/get-repo-since.test.mjs — over a REAL swarm and a REAL replicated firehose, because "the front already holds the frames" is the claim that makes this cheap and a hand-built fixture would not test it. 17 assertions. The load-bearing ones: the no-since path is unchanged (every existing caller), the partial CAR canNOT be walked alone, and prior-state + diff DOES walk to the complete record set. NOTE, pre-existing and NOT from this change: test/no-repo.test.mjs fails at 5aa4f28 (0.2.10 "listRecords honours limit, reverse and cursor") and test/fork.test.mjs was already failing before it at decfb82. Both reproduce identically with this commit reverted. Filed separately rather than fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fe7b86bc9f643f3eb9bb921abe5ff73241afeedf
by plankatron <fleeky@gmail.com> · 2026-08-07T16:34:22.000Z

Changed files (3) +184 -5

lib/did-peer.js +61 -3
……@@ -23,6 +23,7 @@ import ProtomuxRPC from 'protomux-rpc'
2323import { RepoStore } from 'atproto-store/lib/repo-store.js'
2424import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
2525import { decodeCommitFrame, syncFrame } from 'atproto-store/lib/firehose.js'
26import { repoDiffSince } from 'atproto-store/lib/repo-diff.js'
2627import { getRepoFromCommit, getRecord as rvGetRecord, listRecords as rvListRecords } from 'atproto-store/lib/read-view.js'
2728import { readCar, loadCommit, decodeBlock, listRecords as carListRecords } from 'atproto-repo/lib/repo.js'
2829import { verifyCommit, commitCid } from 'atproto-repo/lib/commit.js'
……@@ -273,9 +274,27 @@ export class DidPeer {
273274
274275 // com.atproto.sync.getRepo -> keyless signed CAR (records rebuilt from the replicated repo, signature
275276 // from the verified firehose commit; getRepoFromCommit guards data-root integrity).
276 async getRepo () {
277 // `since` (com.atproto.sync.getRepo?since) — the PARTIAL CAR. Answered from the REPLICATED FIREHOSE,
278 // which every front already holds in both modes (_openCores opens `seq` whether or not there is a repo
279 // core), so this needs no RPC to the peer, no new storage and no change to hpp. Each #commit frame
280 // carries the blocks that were new at that commit; the union over the frames newer than `since` is the
281 // diff. See atproto-store/lib/repo-diff.js for the coverage proof and its refusals.
282 //
283 // The result ALWAYS declares what happened: `partial` and `sinceHonoured` are set explicitly, and a
284 // refusal falls back to the full CAR with a `reason`. A caller must never have to infer from the bytes
285 // which one it got — inferring is what "accepted and ignored" made everyone do.
286 async getRepo (opts) {
277287 if (!this.head) throw new Error(`DidPeer: no verified head yet for ${this.did}`)
278 if (this.repo) return getRepoFromCommit(this.repo, this.head.signed)
288 const since = opts && typeof opts === 'object' ? (opts.since || null) : null
289 if (since) {
290 const diff = await this._repoDiffSince(since)
291 if (diff) return diff
292 // fall through to the full CAR, with the refusal reported by the caller
293 }
294 if (this.repo) {
295 const full = await getRepoFromCommit(this.repo, this.head.signed)
296 return { ...full, partial: false, sinceHonoured: since ? false : null, since, reason: since ? this._sinceRefusal : null }
297 }
279298 // STATELESS PEER: no membrane-repo core to rebuild from, because the peer derives the CAR from its
280299 // record FILES instead of projecting them (serve-membrane projectRepoCore:false). Ask for it.
281300 //
……@@ -291,7 +310,46 @@ export class DidPeer {
291310 const signed = loadCommit(await readCar(carBytes)).value
292311 if (!verifyCommit(signed, this.verifyKey)) throw new Error(`DidPeer: derived CAR commit does not verify for ${this.did}`)
293312 if (signed.rev < this.head.rev) throw new Error(`DidPeer: derived CAR is older than the verified head (${signed.rev} < ${this.head.rev})`)
294 return { carBytes, commitCid: commitCid(signed), dataCid: signed.data }
313 return { carBytes, commitCid: commitCid(signed), dataCid: signed.data, partial: false, sinceHonoured: since ? false : null, since, reason: since ? this._sinceRefusal : null }
314 }
315
316 // The partial CAR, or null when we cannot prove one is complete. Never throws: a coverage failure is a
317 // normal answer here (the caller serves the full repo instead), not an error, and turning it into one
318 // would take down a getRepo that has a perfectly good full answer available.
319 //
320 // We pass the head's CID — derived from the commit VERIFIED against the DID key — and let repo-diff
321 // LOCATE those bytes in the firehose rather than handing them over. That is deliberately stricter: a
322 // CAR rooted at a commit the firehose does not carry cannot be walked by anyone, so a head we cannot
323 // find is a refusal instead of a block we synthesise. It also keeps a dag-cbor dependency out of this
324 // package, which has none and is a symlinked sibling.
325 async _repoDiffSince (since) {
326 this._sinceRefusal = null
327 if (!this.seq) { this._sinceRefusal = 'no firehose replicated for this DID'; return null }
328 try {
329 const r = await repoDiffSince({
330 getFrame: (i) => this.seq.core.get(i),
331 length: this.seq.core.length,
332 since,
333 headCommitCid: commitCid(this.head.signed),
334 headRev: this.head.rev
335 })
336 if (!r || !r.ok) { this._sinceRefusal = (r && r.reason) || 'could not assemble a diff'; return null }
337 return {
338 carBytes: r.carBytes,
339 commitCid: commitCid(this.head.signed),
340 dataCid: this.head.signed.data,
341 partial: true,
342 sinceHonoured: true,
343 since,
344 fromRev: r.fromRev,
345 toRev: r.toRev,
346 empty: !!r.empty,
347 blocks: r.blocks
348 }
349 } catch (e) {
350 this._sinceRefusal = 'diff assembly failed: ' + ((e && e.message) || String(e))
351 return null
352 }
295353 }
296354
297355 // The record set for a peer with NO repo core, derived from the CAR getRepo() already fetches and
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-did-peer",
3 "version": "0.2.10",
3 "version": "0.2.11",
44 "description": "Keyless, direction-free P2P reader/seeder for an atproto DID's repo+firehose Hypercores: announce-push discovery + event-driven head reconciliation + a keyless read surface. The shared primitive beneath atproto-bridge, atproto-relay and favela (extracted from atproto-bridge did-peer.js).",
55 "type": "module",
66 "main": "index.js",
……@@ -8,7 +8,7 @@
88 ".": "./index.js"
99 },
1010 "scripts": {
11 "test": "node test/commitops.test.mjs && node test/router.test.mjs && node test/co-owned.test.mjs && node test/coowner-set.test.mjs && node test/evict.test.mjs && node test/fork.test.mjs && node test/rpc-candidates.test.mjs && node test/no-repo.test.mjs",
11 "test": "node test/commitops.test.mjs && node test/router.test.mjs && node test/co-owned.test.mjs && node test/coowner-set.test.mjs && node test/evict.test.mjs && node test/fork.test.mjs && node test/rpc-candidates.test.mjs && node test/no-repo.test.mjs && node test/get-repo-since.test.mjs",
1212 "test:live": "node test/read-live.mjs"
1313 },
1414 "dependencies": {
test/get-repo-since.test.mjs +121 -0
……@@ -0,0 +1,121 @@
1// DidPeer.getRepo({ since }) — the PARTIAL CAR, over a real replicated firehose (org #34).
2//
3// The membrane accepted `since` and threw it away: pds/lib/server.js called front.getRepo() with no
4// arguments, so it never reached the peer and the identical full CAR came back, byte for byte. The trap
5// is not the wasted bytes — it is that a caller passing `since` got correct results BY ACCIDENT and
6// would have been silently wrong the day partial CARs landed. This is that day, so the contract has to
7// be nailed down here rather than inferred at the membrane.
8//
9// This runs over a real swarm + a real replicated firehose (not a hand-built one), because the whole
10// mechanism is "the front already holds the frames" — a fixture that hands the frames over directly
11// would not test the claim that makes this cheap.
12//
13// Run: node test/get-repo-since.test.mjs
14import { mkdtempSync, rmSync } from 'node:fs'
15import { join } from 'node:path'
16import { tmpdir } from 'node:os'
17import createTestnet from '@hyperswarm/testnet'
18import Hyperswarm from 'hyperswarm'
19import { RepoStore } from 'atproto-store/lib/repo-store.js'
20import { RepoManager } from 'atproto-store/lib/repo-manager.js'
21import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
22import { openCorestore } from 'atproto-store/lib/corestore.js'
23import { createAtprotoIdentityFromKey } from 'atproto-identity/lib/atproto-identity.js'
24import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
25import { readCar, listRecords, loadCommit, writeCar } from 'atproto-repo/lib/repo.js'
26import { DidPeer } from '../index.js'
27
28const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
29const id = createAtprotoIdentityFromKey(new Uint8Array(32).fill(9), { handle: 'since.test', pdsUrl: 'https://pds.local' })
30const testnet = await createTestnet(3)
31
32// ---- holder: a real repo with a real sequenced firehose ---------------------------------------------
33const hDir = mkdtempSync(join(tmpdir(), 'since-holder-'))
34const hStore = openCorestore(hDir)
35const repo = await new RepoStore(hStore, { name: `atproto-repo:${id.did}` }).ready()
36let micros = 1_700_000_000_000_000n
37const mgr = await new RepoManager(repo, { did: id.did, privateKey: id.privateKey, clock: () => (micros += 1_000_000n) }).init()
38const seq = await new FirehoseSequencer(hStore.get({ name: 'firehose' })).ready()
39
40let t0 = Date.parse('2026-08-07T00:00:00.000Z')
41const write = async (rkey, text) => {
42 const w = await mgr.applyWrite({ action: 'create', collection: 'app.bsky.feed.post', rkey, record: { $type: 'app.bsky.feed.post', text, createdAt: new Date(t0 += 1000).toISOString() } })
43 await seq.sequenceCommit(w, { time: new Date(t0).toISOString() })
44 return w
45}
46const w1 = await write('a1', 'first')
47// Snapshot what a caller would hold at w1, NOW — before the repo advances. getRepoFromCommit rebuilds
48// the MST from the store's CURRENT records and refuses if it does not match the commit handed in, so
49// asking for w1's repo after two more writes correctly throws. (It did, and the refusal was right.)
50const priorCar = await readCar((await (await import('atproto-store/lib/read-view.js')).getRepoFromCommit(repo, w1.signedCommit)).carBytes)
51const w2 = await write('a2', 'second')
52const w3 = await write('a3', 'third')
53
54const holder = new DidPeer(hStore, { did: id.did, verifyKey: id.publicKey })
55await holder.provide({ repoKey: repo.core.key, firehoseKey: seq.core.key })
56const hSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
57hSwarm.on('connection', (c) => holder.attach(c))
58await hSwarm.join(discoveryTopic(id.did), { server: true, client: false }).flushed()
59
60// ---- reader: a keyless front, exactly as the membrane fronts one ------------------------------------
61const rStore = openCorestore(mkdtempSync(join(tmpdir(), 'since-reader-')))
62const rSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
63const front = new DidPeer(rStore, { did: id.did, verifyKey: id.publicKey })
64rSwarm.on('connection', (c) => front.attach(c))
65await rSwarm.join(discoveryTopic(id.did), { server: false, client: true }).flushed()
66assert(!!(await front.ready({ timeout: 20000 })), 'front reached a verified head over the swarm')
67
68// Wait for the firehose to replicate all three frames — the diff is assembled FROM them, so a front that
69// has only the head frame would legitimately refuse and the test would prove nothing.
70for (let i = 0; i < 100 && front.seq.core.length < 3; i++) await new Promise((r) => setTimeout(r, 100))
71assert(front.seq.core.length >= 3, 'all three commit frames replicated to the front (' + front.seq.core.length + ')')
72
73// ---- 1. no `since` -> the FULL CAR, unchanged --------------------------------------------------------
74// The regression that matters most: every existing caller passes nothing, and must be byte-identical.
75const full = await front.getRepo()
76const fullCar = await readCar(full.carBytes)
77assert(listRecords(fullCar).length === 3, 'no-since getRepo still returns the complete repo (3 records)')
78assert(full.partial === false, 'and declares itself NOT partial')
79assert(full.sinceHonoured === null, 'sinceHonoured is null when nothing was asked — not false, which would imply a refusal')
80
81// ---- 2. `since` -> a real partial CAR ----------------------------------------------------------------
82const d = await front.getRepo({ since: w1.rev })
83assert(d.partial === true, 'getRepo({since}) returns a PARTIAL CAR')
84assert(d.sinceHonoured === true, 'and says the parameter was honoured')
85assert(d.fromRev === w1.rev && d.toRev === w3.rev, 'reporting the range it covers: ' + d.fromRev + ' -> ' + d.toRev)
86
87const diffCar = await readCar(d.carBytes)
88assert(loadCommit(diffCar).cid.equals(loadCommit(fullCar).cid), 'rooted at the SAME head as the full CAR')
89
90// The defining property: it is not self-contained. If this ever stops throwing, we are serving a full
91// CAR again and have silently regressed to accept-and-ignore.
92let walked = null
93try { listRecords(diffCar); walked = 'walked' } catch { walked = 'threw' }
94assert(walked === 'threw', 'the partial CAR is NOT self-contained — walking it alone throws')
95
96// ---- 3. it COMPLETES a caller prior state ------------------------------------------------------------
97// The only check that proves the omission was sufficient rather than merely plausible.
98const union = new Map([...priorCar.blocks, ...diffCar.blocks])
99const merged = await readCar(await writeCar(loadCommit(fullCar).cid, [...union.values()]))
100const texts = listRecords(merged).map((r) => r.value.text).sort()
101assert(JSON.stringify(texts) === JSON.stringify(['first', 'second', 'third']),
102 'prior state + diff walks to the COMPLETE record set: ' + texts.join(','))
103
104// ---- 4. an unanswerable `since` falls back to the FULL CAR and says so -------------------------------
105// Never a 500, never a silent full CAR pretending to be a diff. '2222222222222' is below every real rev
106// (the TID alphabet starts at '2', not 'a'), so the firehose provably cannot reach back to it.
107const fb = await front.getRepo({ since: '2222222222222' })
108assert(fb.partial === false, 'an unanswerable since falls back to a full CAR')
109assert(fb.sinceHonoured === false, 'and reports that it was NOT honoured — the anti-silence property')
110assert(typeof fb.reason === 'string' && fb.reason.length > 0, 'with a reason: ' + fb.reason)
111assert(listRecords(await readCar(fb.carBytes)).length === 3, 'and the fallback CAR is genuinely complete')
112
113// ---- 5. since == head -> an empty diff ---------------------------------------------------------------
114const empty = await front.getRepo({ since: w3.rev })
115assert(empty.partial === true && empty.empty === true, 'since == head gives an EMPTY diff, not an error')
116assert((await readCar(empty.carBytes)).blocks.size === 1, 'carrying the commit block alone')
117
118console.log('\nGET-REPO-SINCE OK: partial CARs are real, self-declaring, and refuse rather than mislead')
119await hSwarm.destroy(); await rSwarm.destroy(); await testnet.destroy()
120try { rmSync(hDir, { recursive: true, force: true }) } catch {}
121process.exit(0)