OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / 569b92a

atproto-bridge 0.9.20: persist handle->did map (survives restart)
The handle->did map (com.atproto.identity.resolveHandle + /.well-known/atproto-did) was in-memory only, learned at front-time. On a bridge RESTART it was wiped; live peers re-taught it on reconnect, but an OFFLINE peer served only by the blind-relay (ashatron) could not — so ashatron.7rnx.net went unresolvable (HandleNotFound) while its data still flowed. Root cause confirmed in source. Fix 1 (persist): PeerRouter takes handles + onHandles; pds-serve loads/saves <storage>/handle-map.json (mirrors the relay-seq-base persistence). Survives restart. Fix 2 (re-learn): _setHandle is called in _identify→learn on EVERY announce (not just first front), so a cached/reconnecting front re-teaches its handle — the blind-relay already announces the offline peer's handle. Test: handle-persistence.test.js (6/6) — incl. "after restart, the OFFLINE peer handle STILL resolves". peer-router.test.js unchanged (2/2, 10/10). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
569b92ae0c5f876bb3885bde9033499ffce2826d
by plankatron <fleeky@gmail.com> · 2026-07-16T14:20:00.000Z

Changed files (4) +66 -6

package.json +2 -2
……@@ -1,7 +1,7 @@
11{
22 "name": "atproto-bridge",
3 "version": "0.9.19",
4 "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.",
3 "version": "0.9.20",
4 "description": "Stateless keyless atproto MEMBRANE \u2014 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",
77 "bin": {
pds/bin/pds-serve.js +7 -1
……@@ -52,7 +52,13 @@ const persistSeqBase = (n) => { try { fs.mkdirSync(cfg.storage, { recursive: tru
5252// The relay aggregates each fronted peer's signed firehose into one monotonic subscribeRepos
5353// stream, so bsky's relay crawls the whole set in one shot (the egress membrane).
5454const relay = await new FleetRelay(frontStore.get({ name: 'peer-relay-firehose', valueEncoding: 'json' }), { seqBase, onSeqBase: persistSeqBase }).ready()
55const peers = new PeerRouter(swarm, frontStore, { fronted: cfg.frontedDids, relay })
55// PERSISTED handle->did map (survives restart — an offline blind-relayed peer, e.g. ashatron, can't
56// re-teach its handle at boot, so an in-memory-only map left it unresolvable after a restart).
57const handleMapFile = path.join(cfg.storage, 'handle-map.json')
58let handles = {}
59try { handles = JSON.parse(fs.readFileSync(handleMapFile, 'utf8')) || {} } catch {}
60const persistHandles = (obj) => { try { fs.mkdirSync(cfg.storage, { recursive: true }) } catch {}; fs.writeFileSync(handleMapFile, JSON.stringify(obj)) }
61const peers = new PeerRouter(swarm, frontStore, { fronted: cfg.frontedDids, relay, handles, onHandles: persistHandles })
5662
5763// 1. bsky HTTP+WS membrane — keyless per-DID routing to the fronted peers.
5864const server = createPdsServer({ peers, serverDid: cfg.serverDid, availableUserDomains: [cfg.userDomain], favelaWebUpstream: cfg.favelaWebUpstream })
pds/lib/peer-router.js +17 -3
……@@ -33,13 +33,18 @@ export async function resolvePlcVerifyKey (did, { plcUrl = 'https://plc.director
3333}
3434
3535export class PeerRouter {
36 constructor (swarm, corestore, { resolveVerifyKey = resolvePlcVerifyKey, fronted = [], relay = null, refreshMs = 10000, refreshTimeoutMs = 5000, staleStrikeLimit = 3 } = {}) {
36 constructor (swarm, corestore, { resolveVerifyKey = resolvePlcVerifyKey, fronted = [], relay = null, refreshMs = 10000, refreshTimeoutMs = 5000, staleStrikeLimit = 3, handles = {}, onHandles = null } = {}) {
3737 this.swarm = swarm
3838 this.corestore = corestore
3939 this._resolveVerifyKey = resolveVerifyKey // async (did) -> secp256k1 pubkey
4040 this._fronted = new Set(fronted) // DIDs this bridge fronts (vs local Pds accounts)
4141 this._fronts = new Map() // did -> PeerFront (verified, cached)
42 this._handleToDid = new Map() // handle -> did (learned from the announce)
42 // handle -> did. PERSISTED (loaded from `handles`, saved via onHandles) so a bridge restart does NOT
43 // drop handle resolution — an OFFLINE peer (served only by the blind-relay, e.g. ashatron) cannot
44 // re-teach its handle at boot, so an in-memory-only map left it unresolvable (com.atproto.identity.
45 // resolveHandle → HandleNotFound). Learned on EVERY announce (see _identify→learn), not just first front.
46 this._handleToDid = new Map(Object.entries(handles || {}))
47 this._onHandles = onHandles // (obj) => void — persist the handle map when it grows
4348 this.relay = relay // optional FleetRelay: aggregates fronted peers' firehoses
4449 // Freshness watchdog config (see _refreshFronts): the front's firehose core is a REMOTE replica;
4550 // over a flaky P2P link a connection can go counted-but-stalled (swarm still lists it, but passive
……@@ -135,6 +140,7 @@ export class PeerRouter {
135140 // cores re-replicate via corestore, so reads live-follow regardless).
136141 const learn = (coords, rpcForConn) => {
137142 if (!coords || !coords.did || !coords.repoKey || !coords.firehoseKey) return
143 if (coords.handle) this._setHandle(coords.handle, coords.did) // learn+persist on EVERY announce (not just first front) — survives restart + offline (blind-relayed) peers
138144 const cur = this._byDid.get(coords.did)
139145 if (cur && cur.conn === conn) return
140146 conn._frontDid = coords.did
……@@ -222,6 +228,14 @@ export class PeerRouter {
222228 isFronted (did) { return !!did && this._fronted.has(did) }
223229 // handle -> did for fronted peers (so the bridge resolves e.g. p2p.7rnx.net -> wj62).
224230 resolveHandle (handle) { return this._handleToDid.get(handle) || null }
231
232 // Learn (and PERSIST) a handle->did. Idempotent; only persists when the mapping actually changes, so a
233 // steady stream of re-announces is cheap. Persistence is what survives a bridge restart for OFFLINE peers.
234 _setHandle (handle, did) {
235 if (!handle || !did || this._handleToDid.get(handle) === did) return
236 this._handleToDid.set(handle, did)
237 if (this._onHandles) { try { this._onHandles(Object.fromEntries(this._handleToDid)) } catch {} }
238 }
225239 add (did) { this._fronted.add(did); this._joinDid(did); return this }
226240 list () { return [...this._fronted] }
227241
……@@ -243,7 +257,7 @@ export class PeerRouter {
243257 this._lastAppend.set(did, Date.now())
244258 f.seq.core.on('append', () => this._lastAppend.set(did, Date.now()))
245259 }
246 if (f.handle) this._handleToDid.set(f.handle, did) // learn handle->did for resolveHandle
260 if (f.handle) this._setHandle(f.handle, did) // learn+persist handle->did for resolveHandle
247261
248262 // fold the peer's signed firehose into the aggregate so bsky's relay crawls it in the one
249263 // monotonic subscribeRepos stream (FleetSequencer re-stamps a global seq).
pds/test/handle-persistence.test.js +40 -0
……@@ -0,0 +1,40 @@
1// The bridge's handle->did map is PERSISTED, so a restart never drops handle resolution — the bug where an
2// OFFLINE peer (served only by the blind-relay, e.g. ashatron) became unresolvable (resolveHandle →
3// HandleNotFound) after a bridge restart, because the in-memory map was learned only at front-time and the
4// offline peer couldn't re-teach its handle. Fix 1 = load/save the map; Fix 2 = learn it on every announce.
5import test from 'brittle'
6import { mkdtempSync } from 'node:fs'
7import { tmpdir } from 'node:os'
8import { join } from 'node:path'
9import Hyperswarm from 'hyperswarm'
10import createTestnet from '@hyperswarm/testnet'
11import { openCorestore } from 'atproto-store/lib/corestore.js'
12import { PeerRouter } from '../lib/peer-router.js'
13
14test('handle map persists + survives a bridge restart (offline peer stays resolvable)', async (t) => {
15 const testnet = await createTestnet(3)
16 const swarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
17 const store = openCorestore(mkdtempSync(join(tmpdir(), 'hpr-')))
18 t.teardown(async () => { try { await swarm.destroy() } catch {} try { await store.close() } catch {} await testnet.destroy() })
19
20 // Fix 1: constructing with a persisted map restores handle resolution — no live re-front needed.
21 const r1 = new PeerRouter(swarm, store, { refreshMs: 0, handles: { 'ashatron.7rnx.net': 'did:plc:ash' } })
22 t.is(r1.resolveHandle('ashatron.7rnx.net'), 'did:plc:ash', 'loads a persisted handle on construct')
23
24 // Fix 1+2: learning a new handle persists it via onHandles (this is what _identify→learn does per announce).
25 let saved = null
26 const r2 = new PeerRouter(swarm, store, { refreshMs: 0, onHandles: (o) => { saved = o } })
27 r2._setHandle('p2p.7rnx.net', 'did:plc:wj62')
28 t.is(r2.resolveHandle('p2p.7rnx.net'), 'did:plc:wj62', 'learns a new handle')
29 t.ok(saved && saved['p2p.7rnx.net'] === 'did:plc:wj62', 'persists via onHandles when the map grows')
30 saved = null
31 r2._setHandle('p2p.7rnx.net', 'did:plc:wj62') // same mapping again
32 t.is(saved, null, 'idempotent — no re-persist when nothing changed')
33
34 // "Restart": a fresh router loaded with the persisted map resolves BOTH — even the offline peer that
35 // never reconnects. This is the exact scenario that was broken (ashatron after a bridge restart).
36 const persisted = { 'ashatron.7rnx.net': 'did:plc:ash', 'p2p.7rnx.net': 'did:plc:wj62' }
37 const r3 = new PeerRouter(swarm, store, { refreshMs: 0, handles: persisted })
38 t.is(r3.resolveHandle('ashatron.7rnx.net'), 'did:plc:ash', 'after restart, the OFFLINE peer handle STILL resolves')
39 t.is(r3.resolveHandle('p2p.7rnx.net'), 'did:plc:wj62', 'after restart, the learned handle persists')
40})