OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / 0d54440

0.9.26: front ALL co-owners of a DID, serve reads from the freshest
A co-owned DID (wj62 = stortron notary + tabertron co-owner) has every co-owner announce the SAME did with its OWN membrane keys (deterministic identical content, differing only in lag). The router fronted whichever co-owner announced FIRST and stuck — so it could lock onto a lagging/stale membrane and serve a frozen head until a bridge restart (the wj62 symptom). _fronts is now did -> Map<firehoseKey, PeerFront> (a set of fronts per did). front() opens every announced co-owner and returns the FRESHEST (longest firehose), PREFERRING a front whose key is still live-announced in _byDid (so a rebuilt peer's dead old core — which can tie the new one on length — is not served). The relay folds a SINGLE freshest source per did (avoids duplicate #commit frames), switching + re-#sync on a source change/stall. Repro: pds/test/co-owned-front-thrash.test.js (ahead vs behind co-owner). Full bridge suite 21/21 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0d54440b25b0c9400ab58130b0b0fdbeef7e9867
by plankatron <fleeky@gmail.com> · 2026-07-22T00:54:26.000Z

Changed files (6) +256 -160

package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-bridge",
3 "version": "0.9.25",
3 "version": "0.9.26",
44 "description": "Stateless keyless atproto MEMBRANE — fronts self-custody hyper-proto-peers to bluesky over HTTP/WS, holds no keys and never signs. Bare-runnable. Deploy root / flat closure over the identity+repo+store+pds packages.",
55 "type": "module",
66 "main": "index.js",
pds/lib/peer-router.js +168 -149
……@@ -6,6 +6,14 @@
66// AUTHENTICATES it against the DID's signing key (resolved from the DID doc) before serving — so
77// the bridge holds no key and can't be tricked into fronting a squatter. `isFronted(did)` lets
88// server-bare route each request to the right home.
9//
10// CO-OWNED DIDs (multiple self-custody peers of the SAME did — e.g. wj62 = stortron notary + tabertron
11// co-owner): every co-owner runs serve-membrane, so every co-owner announces the SAME did with its OWN
12// membrane core keys (each device projects the shared, deterministic __commit__ log into its own named
13// firehose/repo cores → distinct keys, IDENTICAL content, differing only in replication lag). We therefore
14// front ALL of a did's co-owners (a SET of fronts per did) and serve each read from the FRESHEST
15// (max firehose length). This replaces the old "front whichever announced first and stick" model, which
16// could lock onto a lagging/stale co-owner and serve a frozen head until a bridge restart.
917
1018import b4a from 'b4a'
1119import c from 'compact-encoding'
……@@ -37,8 +45,12 @@ export class PeerRouter {
3745 this.swarm = swarm
3846 this.corestore = corestore
3947 this._resolveVerifyKey = resolveVerifyKey // async (did) -> secp256k1 pubkey
48 this._verifyKeys = new Map() // did -> Promise<pubkey> (resolve once, reuse for every co-owner front)
4049 this._fronted = new Set(fronted) // DIDs this bridge fronts (vs local Pds accounts)
41 this._fronts = new Map() // did -> PeerFront (verified, cached)
50 // did -> Map<firehoseKeyHex, PeerFront>. A SET of fronts per did (one per co-owner membrane). Reads
51 // pick the freshest; the map is keyed by firehose core key so each co-owner folds/serves distinctly.
52 this._fronts = new Map()
53 this._opening = new Map() // "did|fhKeyHex" -> in-flight open Promise (concurrent callers AWAIT it)
4254 // handle -> did. PERSISTED (loaded from `handles`, saved via onHandles) so a bridge restart does NOT
4355 // drop handle resolution — an OFFLINE peer (served only by the blind-relay, e.g. ashatron) cannot
4456 // re-teach its handle at boot, so an in-memory-only map left it unresolvable (com.atproto.identity.
……@@ -46,158 +58,170 @@ export class PeerRouter {
4658 this._handleToDid = new Map(Object.entries(handles || {}))
4759 this._onHandles = onHandles // (obj) => void — persist the handle map when it grows
4860 this.relay = relay // optional FleetRelay: aggregates fronted peers' firehoses
49 // Stall-backstop config (see _refreshFronts): the front's cores are REMOTE replicas kept live by a
50 // STANDING download (PeerFront.fromEntry) that flips `downloading:true`, so `corestore.replicate`
51 // re-attaches them direction-free on every reconnect and the download actively pulls. This timer is
52 // ONLY a pull-only staleness ceiling — a bounded update({wait}) nudge when a front has seen no 'append'
53 // for a window. It does NOT destroy connections: that earlier watchdog fought hyperswarm's own 5s
54 // keepalive + topic-rejoin reconnect (and force-destroying an inbound conn is worse — no auto-requeue),
55 // and it masked the real bug (the missing standing download). Set refreshMs<=0 to disable (tests).
61 this._relaySourceKey = new Map() // did -> firehoseKeyHex currently folded into the relay (single source per did — avoids duplicate frames)
5662 this._refreshMs = refreshMs
5763 this._refreshTimeoutMs = refreshTimeoutMs
5864 this._staleStrikeLimit = staleStrikeLimit // retained for back-compat; no longer drives a destroy
59 this._staleStrikes = new Map() // did -> consecutive failed nudges (diagnose() visibility only)
60 this._lastAppend = new Map() // did -> ts of the firehose's last 'append' (event-driven liveness)
65 this._staleStrikes = new Map() // "did|fhKeyHex" -> consecutive failed nudges (diagnose() visibility only)
66 this._lastAppend = new Map() // did -> ts of ANY co-owner firehose's last 'append' (event-driven liveness)
6167
62 // The membrane is ONE edge fronting MANY DIDs (rooms) over ONE swarm — the keet model: keet keeps
63 // one rootCorestore + one swarm, joins each room as a topic, and on EVERY connection does exactly
64 // one rootCorestore.replicate(conn), letting hypercore mux all rooms' cores over that stream
65 // (keet-core index.js:1239). We mirror that: on each connection we (1) replicate the corestore
66 // (all fronted DIDs' cores mux over it) AND (2) establish exactly ONE ProtomuxRPC and learn which
67 // DID this connection serves from the peer's PUSHED announce, caching did -> { conn, rpc, coords }.
68 // front() is then a cache lookup. Probing per-front for coords would open duplicate RPC channels on
69 // shared streams (DUPLICATE_CHANNEL, a boot crash-loop); one-rpc-per-connection + push can't.
70 this._byDid = new Map() // did -> { conn, rpc, coords } (identified connection)
68 // ONE edge fronting MANY DIDs over ONE swarm (keet model): on EVERY connection, replicate the
69 // corestore (all cores mux over it) + open ONE ProtomuxRPC and learn which did+cores this connection
70 // serves from the peer's PUSHED announce. did -> Map<firehoseKeyHex, { conn, rpc, coords }>: multiple
71 // co-owner connections per did coexist (was one).
72 this._byDid = new Map()
7173 this._identified = new WeakSet() // connections we've already opened an identify rpc on
72 this._waiters = new Map() // did -> Set<fn> woken when that DID's connection lands
74 this._waiters = new Map() // did -> Set<fn> woken when ANY co-owner of that did lands
7375
7476 this._onconnection = (conn) => {
7577 this.corestore.replicate(conn) // keet pattern: mux ALL fronted DIDs' cores over this stream
76 this._identify(conn) // + one rpc, learn DID from the announce, cache by DID
78 this._identify(conn) // + one rpc, learn did+cores from the announce, track by (did, firehoseKey)
7779 const rk = conn.remotePublicKey ? b4a.toString(conn.remotePublicKey, 'hex') : null
7880 console.log(JSON.stringify({ msg: 'swarm connection', peer: rk, connections: this.swarm.connections ? this.swarm.connections.size : null }))
7981 conn.on('close', () => { this._onConnClose(conn); console.log(JSON.stringify({ msg: 'swarm disconnection', peer: rk, connections: this.swarm.connections ? this.swarm.connections.size : null })) })
8082 }
8183 this.swarm.on('connection', this._onconnection)
8284
83 // Join every fronted DID's topic up front (like keet activateRoom joins each room's topic on the
84 // shared swarm) so peers connect + get identified in the background, before any request arrives.
8585 for (const did of this._fronted) this._joinDid(did)
8686
87 // Start the freshness watchdog (0/negative refreshMs disables it — used by tests that drive
88 // refresh manually). unref() so it never keeps the process alive on its own.
8987 if (this._refreshMs > 0) {
9088 this._refreshTimer = setInterval(() => { this._refreshFronts().catch(() => {}) }, this._refreshMs)
9189 if (this._refreshTimer && this._refreshTimer.unref) this._refreshTimer.unref()
9290 }
9391 }
9492
95 // Keep every cached front live-following its peer over a possibly-degraded link. For each front:
96 // force `seq.core.update({wait:true})` (a round-trip pull that recovers an idle-but-alive
97 // connection where passive replication has gone quiet). If that repeatedly fails (a zombie
98 // connection: swarm counts it, but the stream is dead), destroy the borrowed connection so
99 // hyperswarm re-establishes a FRESH one — `_onConnClose` drops the `_byDid` entry, the reconnect
100 // re-runs `_identify` (refreshing the front's rpc/conn) and `corestore.replicate(newConn)`
101 // re-attaches the cores → passive live-follow resumes. A fresh connection is empirically the only
102 // thing that recovers a stalled front (offline repro: a clean reconnect self-heals; prod over a
103 // flaky link does not, until re-connected). Best-effort + fully guarded — never throws.
104 async _refreshFronts () {
105 for (const [did, f] of this._fronts) {
93 // Cached DID→verifyKey (resolved once, reused when opening a front for each co-owner of the did).
94 _verifyKey (did) {
95 if (!this._verifyKeys.has(did)) this._verifyKeys.set(did, Promise.resolve(this._resolveVerifyKey(did)))
96 return this._verifyKeys.get(did)
97 }
98
99 // The freshest OPEN front for a did = the co-owner membrane with the longest replicated firehose.
100 // PREFER a front whose (did, firehoseKey) is CURRENTLY announced (live in _byDid): a peer that REBUILT
101 // its cores (or a stale membrane a daemon abandoned) leaves a frozen front behind whose OLD key is no
102 // longer announced — and it can tie the new core on raw length (both 1 commit), so a pure length pick
103 // would keep serving the dead one. Among live co-owners take the longest firehose; fall back to any open
104 // front only if none are currently announced (all co-owners momentarily disconnected → serve last-known).
105 _freshestFront (did) {
106 const set = this._fronts.get(did)
107 if (!set || !set.size) return null
108 const live = this._byDid.get(did) || new Map()
109 let best = null; let bestLive = null
110 for (const [fhKeyHex, f] of set) {
106111 if (!f || !f.seq || !f.seq.core) continue
107 // EVENT-INFORMED: if the firehose appended within the last window the front is demonstrably live
108 // (PeerFront's standing per-core download is delivering) — skip the nudge entirely.
109 if (Date.now() - (this._lastAppend.get(did) || 0) < this._refreshMs) { this._staleStrikes.set(did, 0); continue }
110 // PULL-ONLY stall backstop (staleness ceiling): a bounded update({wait}) that pulls from whatever peer
111 // is currently attached. It NEVER destroys the connection. The old strike-then-`f._conn.destroy()`
112 // escalation was an anti-pattern: it fought hyperswarm's own 5s keepalive + topic-rejoin reconnect
113 // (and force-destroying an INBOUND connection is strictly worse — hyperswarm does not auto-requeue
114 // those), AND it masked the real bug — the front had no standing download, so `corestore.replicate`
115 // never re-attached the core to a reconnected stream (`_shouldReplicate` needs `downloading:true`).
116 // PeerFront.fromEntry's standing download now makes recovery direction-free + event-driven; this
117 // backstop only nudges a quiet-but-alive link. Strikes are tracked for `diagnose()` visibility only.
118 try { await withTimeout(f.seq.core.update({ wait: true }), this._refreshTimeoutMs); this._staleStrikes.set(did, 0) }
119 catch { this._staleStrikes.set(did, (this._staleStrikes.get(did) || 0) + 1) }
112 if (!best || f.seq.core.length > best.seq.core.length) best = f
113 if (live.has(fhKeyHex) && (!bestLive || f.seq.core.length > bestLive.seq.core.length)) bestLive = f
114 }
115 return bestLive || best
116 }
117
118 // Open (once) the front for ONE co-owner (did, firehoseKey) from its identified connection. Idempotent
119 // + dedup-guarded. Best-effort: a co-owner that doesn't verify/lacks a commit yet is simply skipped —
120 // the OTHER co-owners still front. Wakes did waiters + (re)picks the relay source on success.
121 async _ensureFront (did, fhKeyHex) {
122 const set = this._fronts.get(did)
123 if (set && set.has(fhKeyHex)) return set.get(fhKeyHex)
124 const openKey = did + '|' + fhKeyHex
125 const inflight = this._opening.get(openKey)
126 if (inflight) return inflight // AWAIT the in-progress open (a concurrent front()/learn started it)
127 const entry = this._byDid.get(did) && this._byDid.get(did).get(fhKeyHex)
128 if (!entry) return null
129 const p = (async () => {
130 const verifyKey = await this._verifyKey(did)
131 const f = await PeerFront.fromEntry(this.corestore, this.swarm, entry, did, { verifyKey })
132 if (!this._fronts.has(did)) this._fronts.set(did, new Map())
133 this._fronts.get(did).set(fhKeyHex, f)
134 if (f.seq && f.seq.core && !f._appendWired) {
135 f._appendWired = true
136 this._lastAppend.set(did, Date.now())
137 f.seq.core.on('append', () => { this._lastAppend.set(did, Date.now()) })
138 }
139 if (f.handle) this._setHandle(f.handle, did)
140 const ws = this._waiters.get(did); if (ws) { this._waiters.delete(did); for (const fn of ws) fn() }
141 this._pickRelaySource(did) // fold the freshest into the aggregate (single source per did)
142 return f
143 })().catch(() => null) // a co-owner that doesn't verify / has no commit yet just yields null; others still front
144 this._opening.set(openKey, p)
145 try { return await p } finally { this._opening.delete(openKey) }
146 }
147
148 // Keep every cached front live-following its peer. Cores are REMOTE replicas kept live by the standing
149 // download (PeerFront.fromEntry); this timer is a pull-only staleness ceiling — a bounded update({wait})
150 // nudge when a front has seen no 'append' for a window. It NEVER destroys connections. It ALSO re-picks
151 // the relay source (failover): if the folded co-owner stalled while another advanced, switch to the fresher.
152 async _refreshFronts () {
153 for (const [did, set] of this._fronts) {
154 for (const [fhKeyHex, f] of set) {
155 if (!f || !f.seq || !f.seq.core) continue
156 if (Date.now() - (this._lastAppend.get(did) || 0) < this._refreshMs) { this._staleStrikes.set(did + '|' + fhKeyHex, 0); continue }
157 try { await withTimeout(f.seq.core.update({ wait: true }), this._refreshTimeoutMs); this._staleStrikes.set(did + '|' + fhKeyHex, 0) }
158 catch { this._staleStrikes.set(did + '|' + fhKeyHex, (this._staleStrikes.get(did + '|' + fhKeyHex) || 0) + 1) }
159 }
160 this._pickRelaySource(did) // failover: ensure the relay is folding the freshest co-owner
120161 }
121162 }
122163
123 // Symmetric join: server:true is load-bearing for firewalled↔firewalled holepunch (a one-sided
124 // member never punches outbound). Idempotent — hyperswarm returns the existing discovery session.
125164 _joinDid (did) {
126165 try { this.swarm.join(discoveryTopic(did), { server: true, client: true }); this.swarm.flush().catch(() => {}) } catch {}
127166 }
128167
129 // Open exactly ONE ProtomuxRPC per connection (REUSED for every later call to that peer —
130 // apply-write, sessions, get-service-auth, get-blob — so there is never a second channel on the
131 // stream) and learn the DID it serves from the peer's PUSHED announce. No get-coords round-trip.
168 // Open exactly ONE ProtomuxRPC per connection and learn the did+cores it serves from the peer's PUSHED
169 // announce. A co-owner is tracked by (did, firehoseKey); a NEW announce for the same (did, firehoseKey)
170 // just refreshes conn/rpc, a NEW firehoseKey under the same did is a DISTINCT co-owner (tracked, not evicting).
132171 _identify (conn) {
133172 if (this._identified.has(conn)) return
134173 this._identified.add(conn)
135 // Learn the DID→cores mapping from the pushed announce, cache it, and resolve waiters. Idempotent
136 // per conn. On RECONNECT (front already cached) refresh its write rpc + conn (its repo/firehose
137 // cores re-replicate via corestore, so reads live-follow regardless).
138174 const learn = (coords, rpcForConn) => {
139175 if (!coords || !coords.did || !coords.repoKey || !coords.firehoseKey) return
140 if (coords.handle) this._setHandle(coords.handle, coords.did) // learn+persist on EVERY announce (not just first front) — survives restart + offline (blind-relayed) peers
141 // DURABILITY (no restart on a peer core rebuild): a peer can re-announce NEW repoKey/firehoseKey under
142 // the SAME DID — e.g. after a storage repair that rebuilt its membrane cores. A cached PeerFront is
143 // bound to the DEAD old keys and would serve a FROZEN repo forever (previously only a process restart
144 // fixed it). Detect the key change here and EVICT the stale front (+ drop its aggregate relay source)
145 // so front() re-opens the NEW cores on the next request. Keys compared as hex (announce carries hex).
146 const stale = this._fronts.get(coords.did)
147 if (stale && (b4a.toString(stale.repoKey, 'hex') !== coords.repoKey || b4a.toString(stale.firehoseKey, 'hex') !== coords.firehoseKey)) {
148 this._fronts.delete(coords.did)
149 if (this.relay) { try { this.relay.removeSource(b4a.toString(stale.firehoseKey, 'hex')) } catch {} }
150 try { stale.close() } catch {}
151 }
152 const cur = this._byDid.get(coords.did)
153 // Skip only when NOTHING changed (same connection AND same cores). If the cores changed we must fall
154 // through to refresh _byDid.coords, else front() would rebuild against the old announced keys.
155 const unchanged = cur && cur.conn === conn && cur.coords && cur.coords.repoKey === coords.repoKey && cur.coords.firehoseKey === coords.firehoseKey
156 if (unchanged) return
157 conn._frontDid = coords.did
158 this._byDid.set(coords.did, { conn, rpc: rpcForConn, coords })
159 const f = this._fronts.get(coords.did)
176 if (coords.handle) this._setHandle(coords.handle, coords.did) // learn+persist on EVERY announce
177 const did = coords.did
178 const fhKeyHex = coords.firehoseKey
179 if (!this._byDid.has(did)) this._byDid.set(did, new Map())
180 const entries = this._byDid.get(did)
181 entries.set(fhKeyHex, { conn, rpc: rpcForConn, coords })
182 conn._frontDid = did
183 conn._frontFhKey = fhKeyHex
184 // Refresh an already-open front's write rpc + conn (its cores re-replicate via corestore regardless).
185 const f = this._fronts.get(did) && this._fronts.get(did).get(fhKeyHex)
160186 if (f) { f.rpc = rpcForConn; f._conn = conn }
161 const ws = this._waiters.get(coords.did)
162 if (ws) { this._waiters.delete(coords.did); for (const fn of ws) fn() }
187 // Wake anyone waiting for this did (a co-owner is now available), and start opening this front.
188 const ws = this._waiters.get(did); if (ws) { this._waiters.delete(did); for (const fn of ws) fn() }
189 this._ensureFront(did, fhKeyHex).catch(() => {})
163190 }
164191 try {
165192 const rpc = new ProtomuxRPC(conn, { protocol: RPC_PROTOCOL, valueEncoding: c.json })
166 rpc.on('error', () => {}) // a channel error must never become an uncaught exception
167 // ANNOUNCE-PUSH discovery (atproto/did-announce/v1): the peer PUSHES its core keys on connect —
168 // no round-trip, no serverKey directionality. This channel populates _byDid and is what front()
169 // resolves against; announce-only fronting is proven for the real multi-DID deployment
170 // (test/announce-discovery.test.js). The rpc above is kept for the write/session/blob relay.
193 rpc.on('error', () => {})
171194 const achan = Protomux.from(conn).createChannel({ protocol: ANNOUNCE_PROTOCOL })
172195 if (achan) {
173196 achan.addMessage({ encoding: c.json, onmessage: (a) => { try { learn(a, rpc) } catch {} } })
174197 achan.open()
175198 }
176 } catch {} // not a membrane peer / no RPC surface — rpc dies with the connection
199 } catch {}
177200 }
178201
179 // A peer disconnected: drop only its identified-connection entry, so _identify re-runs when the
180 // peer reconnects. Do NOT close the cached PeerFront — its repo/firehose are corestore cores that
181 // keep replicating over the next connection (the persistent replicate handler), so a fronted
182 // peer's reads LIVE-FOLLOW across a reconnect. The front's write RPC is refreshed on reconnect
183 // (see _identify). Closing the front here broke live-follow-after-reconnect (peer-router test).
202 // A peer disconnected: drop only its (did, firehoseKey) identified-connection entry, so _identify re-runs
203 // when it reconnects. Do NOT close the cached PeerFront — its repo/firehose are corestore cores that keep
204 // replicating over any remaining/next connection (reads live-follow across a reconnect). Its write RPC is
205 // refreshed on reconnect (see _identify). Other co-owners' fronts are untouched.
184206 _onConnClose (conn) {
185207 const did = conn._frontDid
186 if (!did) return
187 const entry = this._byDid.get(did)
188 if (entry && entry.conn === conn) this._byDid.delete(did)
208 const fhKeyHex = conn._frontFhKey
209 if (!did || !fhKeyHex) return
210 const entries = this._byDid.get(did)
211 if (!entries) return
212 const entry = entries.get(fhKeyHex)
213 if (entry && entry.conn === conn) entries.delete(fhKeyHex)
189214 }
190215
191 // Resolve the identified connection for a DID — immediately if already connected, else wait for it
192 // to connect + get identified (up to timeout). This is what makes front() a lookup, not a probe.
216 // Resolve that AT LEAST ONE co-owner of a did is identified — immediately if any is connected, else wait.
193217 _waitForDid (did, timeout) {
194218 const e = this._byDid.get(did)
195 if (e) return Promise.resolve(e)
219 if (e && e.size > 0) return Promise.resolve(e)
196220 return new Promise((resolve, reject) => {
197221 const fn = () => {
198222 clearTimeout(timer)
199223 const e2 = this._byDid.get(did)
200 e2 ? resolve(e2) : reject(new Error(`PeerRouter: ${did} identified but entry missing`))
224 e2 && e2.size > 0 ? resolve(e2) : reject(new Error(`PeerRouter: ${did} identified but entry missing`))
201225 }
202226 const timer = setTimeout(() => {
203227 const ws = this._waiters.get(did); if (ws) ws.delete(fn)
……@@ -208,26 +232,27 @@ export class PeerRouter {
208232 })
209233 }
210234
211 // Introspection — a snapshot of what the membrane is actually doing (see it, don't guess).
212 // All public keys + counts, no secrets. Exposed over the UDS control plane + a debug HTTP route.
213235 diagnose () {
214236 const conns = (this.swarm && this.swarm.connections) ? [...this.swarm.connections] : []
215237 const fronts = []
216 for (const [did, f] of this._fronts) {
217 fronts.push({
218 did,
219 handle: f.handle || null,
220 repoKey: f.repoKey ? b4a.toString(f.repoKey, 'hex') : null,
221 firehoseKey: f.firehoseKey ? b4a.toString(f.firehoseKey, 'hex') : null,
222 firehoseLen: (f.seq && f.seq.core) ? f.seq.core.length : null,
223 rpcConnected: !!(f.rpc && !f.rpc.closed),
224 staleStrikes: this._staleStrikes.get(did) || 0 // watchdog: consecutive failed refreshes
225 })
238 for (const [did, set] of this._fronts) {
239 for (const [fhKeyHex, f] of set) {
240 fronts.push({
241 did,
242 handle: f.handle || null,
243 repoKey: f.repoKey ? b4a.toString(f.repoKey, 'hex') : null,
244 firehoseKey: fhKeyHex,
245 firehoseLen: (f.seq && f.seq.core) ? f.seq.core.length : null,
246 rpcConnected: !!(f.rpc && !f.rpc.closed),
247 relaySource: this._relaySourceKey.get(did) === fhKeyHex,
248 staleStrikes: this._staleStrikes.get(did + '|' + fhKeyHex) || 0
249 })
250 }
226251 }
227252 return {
228253 fronted: [...this._fronted],
229 cachedFronts: this._fronts.size,
230 fronts, // per-front state (firehoseLen/rpcConnected/staleStrikes) — was BUILT but not returned
254 cachedFronts: fronts.length,
255 fronts,
231256 swarm: {
232257 connections: conns.length,
233258 peers: conns.map((c) => (c.remotePublicKey ? b4a.toString(c.remotePublicKey, 'hex') : null))
……@@ -237,11 +262,8 @@ export class PeerRouter {
237262 }
238263
239264 isFronted (did) { return !!did && this._fronted.has(did) }
240 // handle -> did for fronted peers (so the bridge resolves e.g. p2p.7rnx.net -> wj62).
241265 resolveHandle (handle) { return this._handleToDid.get(handle) || null }
242266
243 // Learn (and PERSIST) a handle->did. Idempotent; only persists when the mapping actually changes, so a
244 // steady stream of re-announces is cheap. Persistence is what survives a bridge restart for OFFLINE peers.
245267 _setHandle (handle, did) {
246268 if (!handle || !did || this._handleToDid.get(handle) === did) return
247269 this._handleToDid.set(handle, did)
……@@ -250,42 +272,39 @@ export class PeerRouter {
250272 add (did) { this._fronted.add(did); this._joinDid(did); return this }
251273 list () { return [...this._fronted] }
252274
253 // Get (discovering + verifying on first use, then cached) the PeerFront for a fronted DID.
275 // Get the FRESHEST verified PeerFront for a fronted did. Opens fronts for every identified co-owner
276 // (idempotent), then returns the one with the longest firehose. This is what every read (getRepo,
277 // getLatestCommit, listRecords, …) resolves against — so a lagging co-owner never freezes the served head.
254278 async front (did, { timeout } = {}) {
255279 if (!this.isFronted(did)) throw new Error(`PeerRouter: ${did} is not a fronted DID`)
256 const cached = this._fronts.get(did)
257 if (cached) return cached
258 const verifyKey = await this._resolveVerifyKey(did)
259 this._joinDid(did) // ensure the topic is joined so the peer connects + gets identified
260 const entry = await this._waitForDid(did, timeout || 20000)
261 const f = await PeerFront.fromEntry(this.corestore, this.swarm, entry, did, { verifyKey })
262 this._fronts.set(did, f)
263 // Event-driven liveness: a firehose 'append' (a block arriving via passive replication) PROVES the
264 // front is live — the watchdog uses this to SKIP the update({wait:true}) poll for demonstrably-live
265 // fronts (events set the floor; the timer only catches ABSENCE = a possible stall). Attach once.
266 if (f.seq && f.seq.core && !f._appendWired) {
267 f._appendWired = true
268 this._lastAppend.set(did, Date.now())
269 f.seq.core.on('append', () => this._lastAppend.set(did, Date.now()))
280 this._joinDid(did)
281 if (!this._byDid.has(did) || this._byDid.get(did).size === 0) {
282 await this._waitForDid(did, timeout || 20000)
270283 }
271 if (f.handle) this._setHandle(f.handle, did) // learn+persist handle->did for resolveHandle
284 const entries = this._byDid.get(did) || new Map()
285 await Promise.all([...entries.keys()].map((fhKeyHex) => this._ensureFront(did, fhKeyHex).catch(() => null)))
286 const best = this._freshestFront(did)
287 if (!best) throw new Error(`PeerRouter: no frontable co-owner for ${did}`)
288 return best
289 }
272290
273 // fold the peer's signed firehose into the aggregate so bsky's relay crawls it in the one
274 // monotonic subscribeRepos stream (FleetSequencer re-stamps a global seq).
275 if (this.relay) {
276 // Source id = the firehose CORE key, NOT the did. FleetRelay dedups frames by (sourceId, index);
277 // a peer's firehose core can CHANGE under the same did (custodial→self-custody, or single-tenant→
278 // swarm migration), and the new core's indices 0..n would collide with the old core's already-
279 // aggregated `${did}:0..n` keys → the new core's frames get deduped away and never fold (only the
280 // #sync re-baseline gets through). Keying by core identity lets each distinct firehose fold cleanly.
281 await this.relay.addSource(b4a.toString(f.seq.core.key, 'hex'), f.seq.core)
282 // Emit a #sync (Sync 1.1) so a relay re-baselines to this peer's current commit. This is
283 // load-bearing for a peer whose commit chain is discontinuous vs the relay's last-known
284 // state (e.g. a custodial→self-custody migration): without it the relay keeps expecting
285 // deltas from the old chain and never ingests the new one. Idempotent per rev (deduped).
286 try { await this._emitSync(did, f) } catch { /* best-effort; retriable via emit-sync RPC */ }
287 }
288 return f
291 // Fold the FRESHEST co-owner's firehose into the aggregate (single source per did — two co-owners are the
292 // SAME frames under different sourceIds, so folding both duplicates every #commit to bsky's relay). On a
293 // source change (a fresher co-owner won, or the folded one stalled) swap removeSource→addSource + emit a
294 // #sync so the relay re-baselines. Idempotent when the freshest is unchanged.
295 _pickRelaySource (did) {
296 if (!this.relay) return
297 const best = this._freshestFront(did)
298 if (!best || !best.seq || !best.seq.core) return
299 const key = b4a.toString(best.seq.core.key, 'hex')
300 if (this._relaySourceKey.get(did) === key) return
301 const old = this._relaySourceKey.get(did)
302 this._relaySourceKey.set(did, key)
303 ;(async () => {
304 if (old) { try { this.relay.removeSource(old) } catch {} }
305 try { await this.relay.addSource(key, best.seq.core) } catch {}
306 try { await this._emitSync(did, best) } catch {}
307 })().catch(() => {})
289308 }
290309
291310 // Build + inject a #sync frame for a fronted peer's current signed commit (re-baseline signal).
……@@ -299,7 +318,7 @@ export class PeerRouter {
299318 return { did, rev: signed.rev, emitted }
300319 }
301320
302 // Public trigger (control plane): force a #sync re-baseline for a fronted DID.
321 // Public trigger (control plane): force a #sync re-baseline for a fronted DID (from its freshest front).
303322 async emitSync (did) {
304323 const f = await this.front(did)
305324 return this._emitSync(did, f)
……@@ -308,9 +327,9 @@ export class PeerRouter {
308327 async close () {
309328 if (this._refreshTimer) { try { clearInterval(this._refreshTimer) } catch {} this._refreshTimer = null }
310329 if (this._onconnection) { try { this.swarm.off('connection', this._onconnection) } catch {} }
311 for (const f of this._fronts.values()) { try { await f.close() } catch {} }
330 for (const set of this._fronts.values()) { for (const f of set.values()) { try { await f.close() } catch {} } }
312331 this._fronts.clear()
313 for (const { rpc } of this._byDid.values()) { try { await rpc.end() } catch {} }
332 for (const entries of this._byDid.values()) { for (const { rpc } of entries.values()) { try { await rpc.end() } catch {} } }
314333 this._byDid.clear()
315334 }
316335}
pds/test/co-owned-front-thrash.test.js +77 -0
……@@ -0,0 +1,77 @@
1// REPRO: co-owned DID (the wj62 prod topology — stortron notary + tabertron co-owner). BOTH co-owners
2// run serve-membrane, so BOTH announce the SAME did with THEIR OWN membrane core keys (each device has a
3// distinct named membrane firehose/repo core → distinct keys, even though the projected #commit content is
4// the same). The bridge's _identify->learn treats a DIFFERENT key under the same did as a stale-key change
5// and EVICTS + re-fronts. So when a SECOND co-owner announces (or a co-owner lags behind), the bridge can
6// flip its front onto the BEHIND co-owner and serve a stale head — exactly the "bridge regressed to an old
7// head after a daemon restart" symptom that a manual bridge restart fixed.
8import test from 'brittle'
9import { mkdtempSync, rmSync } from 'node:fs'
10import { tmpdir } from 'node:os'
11import { join } from 'node:path'
12import Hyperswarm from 'hyperswarm'
13import createTestnet from '@hyperswarm/testnet'
14import { openCorestore } from 'atproto-store/lib/corestore.js'
15import { RepoStore } from 'atproto-store/lib/repo-store.js'
16import { RepoManager } from 'atproto-store/lib/repo-manager.js'
17import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
18import { keyFromPrivate } from 'atproto-identity'
19import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
20import { PeerRouter } from '../lib/peer-router.js'
21import { pushAnnounce } from './serve-announce.js'
22
23const DID = 'did:plc:coowned1'
24const HANDLE = 'coowned.7rnx.net'
25const PRIV = new Uint8Array(32).fill(7)
26const NOW = '2026-07-21T00:00:00.000Z'
27const post = (rk, text) => ({ action: 'create', collection: 'app.bsky.feed.post', rkey: rk, record: { $type: 'app.bsky.feed.post', text, createdAt: NOW, langs: ['en'] } })
28
29// Build a co-owner peer: its own corestore/cores, N signed commits over the shared DID key.
30async function coOwner (bootstrap, nCommits) {
31 const dir = mkdtempSync(join(tmpdir(), 'coowner-'))
32 const store = openCorestore(dir)
33 const repo = await new RepoStore(store, { name: `atproto-repo:${DID}` }).ready()
34 let micros = 1_700_000_000_000_000n
35 const mgr = await new RepoManager(repo, { did: DID, privateKey: PRIV, clock: () => (micros += 1_000_000n) }).init()
36 const seq = await new FirehoseSequencer(store.get({ name: 'firehose' })).ready()
37 for (let i = 0; i < nCommits; i++) { const w = await mgr.applyWrite(post('3post' + i + 'aaaaaa2', 'c' + i)); await seq.sequenceCommit(w, { time: NOW }) }
38 const swarm = new Hyperswarm({ bootstrap })
39 swarm.on('connection', (conn) => { store.replicate(conn); pushAnnounce(conn, { did: DID, handle: HANDLE, repo, seq }) })
40 await swarm.join(discoveryTopic(DID), { server: true, client: false }).flushed()
41 return { dir, store, repo, seq, swarm, close: async () => { try { await swarm.destroy() } catch {} ; await seq.close(); await repo.close(); await store.close(); rmSync(dir, { recursive: true, force: true }) } }
42}
43
44test('co-owned: bridge must not front the BEHIND co-owner (front stability under 2 membranes)', async (t) => {
45 const testnet = await createTestnet(3)
46 const AHEAD = await coOwner(testnet.bootstrap, 2) // notary: 2 commits (the current head)
47 const BEHIND = await coOwner(testnet.bootstrap, 1) // co-owner lagging: 1 commit
48
49 const routerStore = openCorestore(mkdtempSync(join(tmpdir(), 'coowned-router-')))
50 const routerSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
51 const router = new PeerRouter(routerSwarm, routerStore, {
52 fronted: [DID], resolveVerifyKey: async () => keyFromPrivate(PRIV).publicKey, refreshMs: 0
53 })
54 t.teardown(async () => { await router.close(); await routerSwarm.destroy(); await routerStore.close(); await AHEAD.close(); await BEHIND.close(); await testnet.destroy() })
55
56 await router.front(DID) // establish the first front
57 // Poll front() FRESH each time (as a real getRepo request does) so re-fronting/eviction is visible.
58 const counts = []
59 const keys = []
60 for (let i = 0; i < 40; i++) {
61 const f = await router.front(DID).catch(() => null)
62 const recs = f ? await f.listRecords('app.bsky.feed.post').catch(() => []) : []
63 counts.push(recs.length)
64 const d = router.diagnose()
65 keys.push((d.fronts[0] && d.fronts[0].firehoseKey) ? d.fronts[0].firehoseKey.slice(0, 8) : '-')
66 await new Promise(r => setTimeout(r, 300))
67 }
68 t.comment('served record counts over time: ' + JSON.stringify(counts))
69 t.comment('fronted firehoseKey over time: ' + JSON.stringify([...new Set(keys)]))
70 // With the freshest-serving fix, reads converge to the AHEAD co-owner (2) and never regress to the
71 // BEHIND one (1) once the ahead is available. Early polls may read 1 before the ahead co-owner's front
72 // opens — that's fine; what matters is it ends on the freshest and never drops back.
73 t.is(counts[counts.length - 1], 2, 'bridge ends up serving the FRESHEST (ahead) co-owner')
74 const firstTwo = counts.indexOf(2)
75 t.ok(firstTwo >= 0, 'the ahead co-owner (2 records) was fronted')
76 t.ok(counts.slice(firstTwo).every((x) => x >= 2), 'once fronting the freshest, it never REGRESSES to the behind co-owner')
77})
pds/test/diagnose-fronts.test.js +3 -3
……@@ -8,15 +8,15 @@ test('diagnose() returns per-front state (fronts array)', (t) => {
88 const fakeSwarm = { on () {}, off () {}, join () { return { flushed: async () => {} } }, flush: async () => {}, connections: new Set() }
99 const router = new PeerRouter(fakeSwarm, {}, { fronted: [], refreshMs: 0 })
1010
11 router._fronts.set('did:plc:abc', {
11 router._fronts.set('did:plc:abc', new Map([['k1', {
1212 handle: 'a.7rnx.net',
1313 serverKey: 'aa',
1414 repoKey: null,
1515 firehoseKey: null,
1616 seq: { core: { length: 7 } },
1717 rpc: { closed: false }
18 })
19 router._staleStrikes.set('did:plc:abc', 2)
18 }]]))
19 router._staleStrikes.set('did:plc:abc|k1', 2)
2020
2121 const d = router.diagnose()
2222 t.ok(Array.isArray(d.fronts), 'diagnose returns a fronts array')
pds/test/live-follow-repro.test.js +3 -3
……@@ -111,8 +111,8 @@ test('watchdog nudges a stalled front with a pull but NEVER destroys the connect
111111 const stalled = { seq: { core: { update: async () => { stalledPulls++; throw new Error('stalled link') } } }, _conn: { destroy: () => { stalledDestroyed++ } } }
112112 let healthyDestroyed = 0
113113 const healthy = { seq: { core: { update: async () => {} } }, _conn: { destroy: () => { healthyDestroyed++ } } }
114 router._fronts.set('did:plc:stalled', stalled)
115 router._fronts.set('did:plc:healthy', healthy)
114 router._fronts.set('did:plc:stalled', new Map([['ks', stalled]]))
115 router._fronts.set('did:plc:healthy', new Map([['kh', healthy]]))
116116
117117 await router._refreshFronts() // pull 1
118118 await router._refreshFronts() // pull 2
……@@ -121,7 +121,7 @@ test('watchdog nudges a stalled front with a pull but NEVER destroys the connect
121121
122122 t.is(stalledPulls, 4, 'a stalled front is NUDGED with an update({wait}) pull each window')
123123 t.is(stalledDestroyed, 0, 'stalled connection is NEVER destroyed — recovery is the standing download + hyperswarm reconnect')
124 t.ok((router._staleStrikes.get('did:plc:stalled') || 0) >= 1, 'failed pulls accrue strikes for diagnose() visibility only')
124 t.ok((router._staleStrikes.get('did:plc:stalled|ks') || 0) >= 1, 'failed pulls accrue strikes for diagnose() visibility only')
125125 t.is(healthyDestroyed, 0, 'a healthy front (update resolves) is never destroyed either')
126126
127127 await router.close()
pds/test/watchdog-event-driven.test.js +4 -4
……@@ -22,13 +22,13 @@ test('watchdog SKIPS the poll for a LIVE front (recent append) — event-informe
2222 seq: { core: { update: () => { updateCalled = true; return Promise.reject(new Error('would fail if polled')) } } },
2323 _conn: { destroy: () => { destroyed = true } }
2424 }
25 router._fronts.set('did:plc:live', live)
25 router._fronts.set('did:plc:live', new Map([['klive', live]]))
2626 router._lastAppend.set('did:plc:live', Date.now()) // appended just now → demonstrably live
2727
2828 await router._refreshFronts()
2929
3030 t.absent(updateCalled, 'live front (recent append) SKIPS the update({wait:true}) poll')
31 t.is(router._staleStrikes.get('did:plc:live') || 0, 0, 'no strike accrued for a live front')
31 t.is(router._staleStrikes.get('did:plc:live|klive') || 0, 0, 'no strike accrued for a live front')
3232 t.absent(destroyed, 'live front connection is NOT dropped')
3333})
3434
……@@ -44,12 +44,12 @@ test('watchdog NUDGES a stalled front but NEVER destroys the connection (pull-on
4444 seq: { core: { update: () => { updateCalls++; return new Promise(() => {}) } } }, // never resolves → withTimeout fires
4545 _conn: { destroy: () => { destroyed = true } }
4646 }
47 router._fronts.set('did:plc:stalled', stalled)
47 router._fronts.set('did:plc:stalled', new Map([['kstalled', stalled]]))
4848 router._lastAppend.set('did:plc:stalled', Date.now() - 10 * 60 * 1000) // no append for 10 min → suspect stall
4949
5050 await router._refreshFronts() // nudge 1 (poll times out)
5151 t.is(updateCalls, 1, 'stalled front (no recent append) IS nudged with a bounded update({wait}) pull')
52 t.is(router._staleStrikes.get('did:plc:stalled'), 1, 'a failed nudge accrues a strike (diagnose visibility only)')
52 t.is(router._staleStrikes.get('did:plc:stalled|kstalled'), 1, 'a failed nudge accrues a strike (diagnose visibility only)')
5353 t.absent(destroyed, 'connection NOT destroyed after a failed nudge')
5454
5555 await router._refreshFronts() // nudge 2 — the OLD strike-limit "drop" point