| … | … | @@ -81,10 +81,14 @@ export class DidPeer { |
| 81 | 81 | if (this.seq) return // firehose is opened in BOTH modes; the idempotency guard (repo may stay null under noRepo) |
| 82 | 82 | if (this._opening) return this._opening |
| 83 | 83 | 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 |
| 85 | 89 | this._firehoseKey = toKey(firehoseKey) |
| 86 | 90 | // 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() |
| 88 | 92 | this.seq = await new FirehoseSequencer(this.corestore.get({ key: this._firehoseKey })).ready() |
| 89 | 93 | // FULL seed so this node can serve the cores to others (download all + follow appends) — this is |
| 90 | 94 | // the "each peer seeds" torrent behaviour; a read-only consumer that does NOT want to seed can |
| … | … | @@ -112,7 +116,7 @@ export class DidPeer { |
| 112 | 116 | const mux = Protomux.from(conn) |
| 113 | 117 | const chan = mux.createChannel({ |
| 114 | 118 | 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 |
| 116 | 120 | onclose: () => this._channels.delete(chan) |
| 117 | 121 | }) |
| 118 | 122 | if (!chan) return // already open on this mux |
| … | … | @@ -122,35 +126,37 @@ export class DidPeer { |
| 122 | 126 | }) |
| 123 | 127 | chan.open() |
| 124 | 128 | this._channels.add(chan) |
| 125 | | if (this.repo) this._announceTo(chan) |
| 129 | if (this.seq) this._announceTo(chan) |
| 126 | 130 | } |
| 127 | 131 | |
| 128 | 132 | // 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 } |
| 130 | 136 | |
| 131 | 137 | // Send this peer's announce over a protomux message (used by DidPeerRouter's shared per-connection |
| 132 | 138 | // channel, where a single channel is multiplexed across many DIDs). |
| 133 | 139 | announceVia (message) { |
| 134 | | if (!this.repo || !message) return |
| 140 | if (!this.seq || !message) return |
| 135 | 141 | try { |
| 136 | 142 | message.send({ |
| 137 | 143 | did: this.did, |
| 138 | | repoKey: b4a.toString(this._repoKey, 'hex'), |
| 144 | repoKey: this._repoKey ? b4a.toString(this._repoKey, 'hex') : null, |
| 139 | 145 | firehoseKey: b4a.toString(this._firehoseKey, 'hex'), |
| 140 | | repoLength: this.repo.core.length, |
| 146 | repoLength: this.repo ? this.repo.core.length : null, |
| 141 | 147 | firehoseLength: this.seq.core.length |
| 142 | 148 | }) |
| 143 | 149 | } catch (e) { this._fail(e) } |
| 144 | 150 | } |
| 145 | 151 | |
| 146 | 152 | _announceTo (chan) { |
| 147 | | if (!this.repo || !chan._announce) return |
| 153 | if (!this.seq || !chan._announce) return |
| 148 | 154 | try { |
| 149 | 155 | chan._announce.send({ |
| 150 | 156 | did: this.did, |
| 151 | | repoKey: b4a.toString(this._repoKey, 'hex'), |
| 157 | repoKey: this._repoKey ? b4a.toString(this._repoKey, 'hex') : null, |
| 152 | 158 | firehoseKey: b4a.toString(this._firehoseKey, 'hex'), |
| 153 | | repoLength: this.repo.core.length, |
| 159 | repoLength: this.repo ? this.repo.core.length : null, |
| 154 | 160 | firehoseLength: this.seq.core.length |
| 155 | 161 | }) |
| 156 | 162 | } catch (e) { this._fail(e) } |
| … | … | @@ -159,8 +165,11 @@ export class DidPeer { |
| 159 | 165 | _announceAll () { for (const chan of this._channels) this._announceTo(chan) } |
| 160 | 166 | |
| 161 | 167 | 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) { |
| 164 | 173 | await this._openCores(a.repoKey, a.firehoseKey) |
| 165 | 174 | this._announceAll() // we can help seed now — tell peers we have them too (symmetric) |
| 166 | 175 | } |
| … | … | @@ -202,8 +211,24 @@ export class DidPeer { |
| 202 | 211 | // com.atproto.sync.getRepo -> keyless signed CAR (records rebuilt from the replicated repo, signature |
| 203 | 212 | // from the verified firehose commit; getRepoFromCommit guards data-root integrity). |
| 204 | 213 | 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 } |
| 207 | 232 | } |
| 208 | 233 | |
| 209 | 234 | // com.atproto.repo.getRecord |