OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-did-peer commits / 4fb437e

repoKey is OPTIONAL — front a STATELESS peer that derives its repo
Unblocks serve-membrane's projectRepoCore:false (SPEC-atproto-overlay-storage §6.3, "the virtual device stores nothing of its own — it derives"). A peer that stops projecting publishes no membrane-repo core, so its announce carries repoKey:null. Every gate here keyed off `this.repo`, so such a peer read as holding nothing and was silently never fronted: - onAnnounce REQUIRED a.repoKey -> the announce was dropped outright - holds / _announceTo / announceVia / attach all gated on this.repo All now key off `this.seq` — the firehose, which is the load-bearing half because it carries the SIGNED commits (the trust anchor). The repo is the derivable half: replicated, read from co-located files, or fetched over RPC. getRepo() falls back to the peer's `get-repo-car` RPC when there is no repo core. The trust model does NOT relax. With a repo core we rebuild the MST locally and getRepoFromCommit asserts it matches the verified commit; the CAR arrives whole instead, so we re-establish the same guarantee by verifying the commit INSIDE it against the DID key AND refusing anything older than the head already verified off the firehose. A forged or rolled-back repo is rejected exactly as before. Announces now carry repoKey/repoLength as null rather than omitting them, so a mixed fleet degrades gracefully in both directions: an unpatched consumer skips a stateless peer (and uses a co-owner that still projects), while a patched consumer fronts either. Suite: commitops + router + co-owned + no-repo all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4fb437e70fae13e24cae64d1c896280e3f80f493
by plankatron <fleeky@gmail.com> · 2026-07-26T23:07:38.000Z

Changed files (2) +41 -16

lib/did-peer.js +40 -15
……@@ -81,10 +81,14 @@ export class DidPeer {
8181 if (this.seq) return // firehose is opened in BOTH modes; the idempotency guard (repo may stay null under noRepo)
8282 if (this._opening) return this._opening
8383 this._opening = (async () => {
84 this._repoKey = toKey(repoKey)
84 // repoKey is OPTIONAL now. A peer running the stateless virtual device (serve-membrane
85 // projectRepoCore:false) publishes no membrane-repo core at all — record bytes exist only as the
86 // content-addressed files, and the repo is DERIVED on demand via the get-repo-car RPC (see
87 // getRepo below). Requiring repoKey here is what previously made such a peer un-frontable.
88 this._repoKey = repoKey ? toKey(repoKey) : null
8589 this._firehoseKey = toKey(firehoseKey)
8690 // noRepo: skip the repo core entirely — never opened, so corestore.replicate never syncs it (no replica).
87 if (!this._noRepo) this.repo = await new RepoStore(this.corestore, { key: this._repoKey }).ready()
91 if (!this._noRepo && this._repoKey) this.repo = await new RepoStore(this.corestore, { key: this._repoKey }).ready()
8892 this.seq = await new FirehoseSequencer(this.corestore.get({ key: this._firehoseKey })).ready()
8993 // FULL seed so this node can serve the cores to others (download all + follow appends) — this is
9094 // the "each peer seeds" torrent behaviour; a read-only consumer that does NOT want to seed can
……@@ -112,7 +116,7 @@ export class DidPeer {
112116 const mux = Protomux.from(conn)
113117 const chan = mux.createChannel({
114118 protocol: ANNOUNCE_PROTOCOL,
115 onopen: () => { if (this.repo) this._announceTo(chan) }, // ANNOUNCE-PUSH on open
119 onopen: () => { if (this.seq) this._announceTo(chan) }, // ANNOUNCE-PUSH on open
116120 onclose: () => this._channels.delete(chan)
117121 })
118122 if (!chan) return // already open on this mux
……@@ -122,35 +126,37 @@ export class DidPeer {
122126 })
123127 chan.open()
124128 this._channels.add(chan)
125 if (this.repo) this._announceTo(chan)
129 if (this.seq) this._announceTo(chan)
126130 }
127131
128132 // Does this peer currently hold (and can serve/seed) the DID's cores?
129 get holds () { return !!this.repo }
133 // We HOLD a DID once the firehose (the signed-commit trust anchor) is open. Previously this keyed
134 // off the repo core, so a stateless peer — which publishes no repo core — read as holding nothing.
135 get holds () { return !!this.seq }
130136
131137 // Send this peer's announce over a protomux message (used by DidPeerRouter's shared per-connection
132138 // channel, where a single channel is multiplexed across many DIDs).
133139 announceVia (message) {
134 if (!this.repo || !message) return
140 if (!this.seq || !message) return
135141 try {
136142 message.send({
137143 did: this.did,
138 repoKey: b4a.toString(this._repoKey, 'hex'),
144 repoKey: this._repoKey ? b4a.toString(this._repoKey, 'hex') : null,
139145 firehoseKey: b4a.toString(this._firehoseKey, 'hex'),
140 repoLength: this.repo.core.length,
146 repoLength: this.repo ? this.repo.core.length : null,
141147 firehoseLength: this.seq.core.length
142148 })
143149 } catch (e) { this._fail(e) }
144150 }
145151
146152 _announceTo (chan) {
147 if (!this.repo || !chan._announce) return
153 if (!this.seq || !chan._announce) return
148154 try {
149155 chan._announce.send({
150156 did: this.did,
151 repoKey: b4a.toString(this._repoKey, 'hex'),
157 repoKey: this._repoKey ? b4a.toString(this._repoKey, 'hex') : null,
152158 firehoseKey: b4a.toString(this._firehoseKey, 'hex'),
153 repoLength: this.repo.core.length,
159 repoLength: this.repo ? this.repo.core.length : null,
154160 firehoseLength: this.seq.core.length
155161 })
156162 } catch (e) { this._fail(e) }
……@@ -159,8 +165,11 @@ export class DidPeer {
159165 _announceAll () { for (const chan of this._channels) this._announceTo(chan) }
160166
161167 async onAnnounce (a) {
162 if (!a || a.did !== this.did || !a.repoKey || !a.firehoseKey) return
163 if (!this.repo) {
168 // repoKey is OPTIONAL — a stateless peer announces { firehoseKey, repoKey: null }. The firehose is
169 // the load-bearing half (it carries the SIGNED commits, i.e. the trust anchor); the repo is either
170 // replicated, read from co-located files, or derived over RPC.
171 if (!a || a.did !== this.did || !a.firehoseKey) return
172 if (!this.seq) {
164173 await this._openCores(a.repoKey, a.firehoseKey)
165174 this._announceAll() // we can help seed now — tell peers we have them too (symmetric)
166175 }
……@@ -202,8 +211,24 @@ export class DidPeer {
202211 // com.atproto.sync.getRepo -> keyless signed CAR (records rebuilt from the replicated repo, signature
203212 // from the verified firehose commit; getRepoFromCommit guards data-root integrity).
204213 async getRepo () {
205 if (!this.repo || !this.head) throw new Error(`DidPeer: no verified head yet for ${this.did}`)
206 return getRepoFromCommit(this.repo, this.head.signed)
214 if (!this.head) throw new Error(`DidPeer: no verified head yet for ${this.did}`)
215 if (this.repo) return getRepoFromCommit(this.repo, this.head.signed)
216 // STATELESS PEER: no membrane-repo core to rebuild from, because the peer derives the CAR from its
217 // record FILES instead of projecting them (serve-membrane projectRepoCore:false). Ask for it.
218 //
219 // The trust model does NOT relax. With a repo core we rebuild the MST locally and getRepoFromCommit
220 // asserts it matches the verified commit; here the CAR arrives whole, so we re-establish the same
221 // guarantee by verifying the commit INSIDE it against the DID key and refusing anything older than
222 // the head we already verified off the firehose. A peer that serves a forged or rolled-back repo is
223 // rejected exactly as before — we never serve bytes we have not authenticated.
224 if (!this.rpc) throw new Error(`DidPeer: ${this.did} has no repo core and no RPC to derive one`)
225 const r = await this.rpc.request('get-repo-car', {})
226 if (!r || !r.car) throw new Error(`DidPeer: ${this.did} served no CAR`)
227 const carBytes = b4a.from(r.car, 'hex')
228 const signed = loadCommit(await readCar(carBytes)).value
229 if (!verifyCommit(signed, this.verifyKey)) throw new Error(`DidPeer: derived CAR commit does not verify for ${this.did}`)
230 if (signed.rev < this.head.rev) throw new Error(`DidPeer: derived CAR is older than the verified head (${signed.rev} < ${this.head.rev})`)
231 return { carBytes, commitCid: commitCid(signed), dataCid: signed.data }
207232 }
208233
209234 // com.atproto.repo.getRecord
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-did-peer",
3 "version": "0.2.2",
3 "version": "0.2.3",
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",