| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "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.", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| 7 | 7 | "bin": { |
| … | … | @@ -52,7 +52,13 @@ const persistSeqBase = (n) => { try { fs.mkdirSync(cfg.storage, { recursive: tru |
| 52 | 52 | // The relay aggregates each fronted peer's signed firehose into one monotonic subscribeRepos |
| 53 | 53 | // stream, so bsky's relay crawls the whole set in one shot (the egress membrane). |
| 54 | 54 | const relay = await new FleetRelay(frontStore.get({ name: 'peer-relay-firehose', valueEncoding: 'json' }), { seqBase, onSeqBase: persistSeqBase }).ready() |
| 55 | const 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). | |
| 57 | const handleMapFile = path.join(cfg.storage, 'handle-map.json') | |
| 58 | let handles = {} | |
| 59 | try { handles = JSON.parse(fs.readFileSync(handleMapFile, 'utf8')) || {} } catch {} | |
| 60 | const persistHandles = (obj) => { try { fs.mkdirSync(cfg.storage, { recursive: true }) } catch {}; fs.writeFileSync(handleMapFile, JSON.stringify(obj)) } | |
| 61 | const peers = new PeerRouter(swarm, frontStore, { fronted: cfg.frontedDids, relay, handles, onHandles: persistHandles }) | |
| 56 | 62 | |
| 57 | 63 | // 1. bsky HTTP+WS membrane — keyless per-DID routing to the fronted peers. |
| 58 | 64 | const server = createPdsServer({ peers, serverDid: cfg.serverDid, availableUserDomains: [cfg.userDomain], favelaWebUpstream: cfg.favelaWebUpstream }) |
| … | … | @@ -33,13 +33,18 @@ export async function resolvePlcVerifyKey (did, { plcUrl = 'https://plc.director |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | export 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 } = {}) { | |
| 37 | 37 | this.swarm = swarm |
| 38 | 38 | this.corestore = corestore |
| 39 | 39 | this._resolveVerifyKey = resolveVerifyKey // async (did) -> secp256k1 pubkey |
| 40 | 40 | this._fronted = new Set(fronted) // DIDs this bridge fronts (vs local Pds accounts) |
| 41 | 41 | 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 | |
| 43 | 48 | this.relay = relay // optional FleetRelay: aggregates fronted peers' firehoses |
| 44 | 49 | // Freshness watchdog config (see _refreshFronts): the front's firehose core is a REMOTE replica; |
| 45 | 50 | // 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 { |
| 135 | 140 | // cores re-replicate via corestore, so reads live-follow regardless). |
| 136 | 141 | const learn = (coords, rpcForConn) => { |
| 137 | 142 | 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 | |
| 138 | 144 | const cur = this._byDid.get(coords.did) |
| 139 | 145 | if (cur && cur.conn === conn) return |
| 140 | 146 | conn._frontDid = coords.did |
| … | … | @@ -222,6 +228,14 @@ export class PeerRouter { |
| 222 | 228 | isFronted (did) { return !!did && this._fronted.has(did) } |
| 223 | 229 | // handle -> did for fronted peers (so the bridge resolves e.g. p2p.7rnx.net -> wj62). |
| 224 | 230 | 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 | } | |
| 225 | 239 | add (did) { this._fronted.add(did); this._joinDid(did); return this } |
| 226 | 240 | list () { return [...this._fronted] } |
| 227 | 241 | |
| … | … | @@ -243,7 +257,7 @@ export class PeerRouter { |
| 243 | 257 | this._lastAppend.set(did, Date.now()) |
| 244 | 258 | f.seq.core.on('append', () => this._lastAppend.set(did, Date.now())) |
| 245 | 259 | } |
| 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 | |
| 247 | 261 | |
| 248 | 262 | // fold the peer's signed firehose into the aggregate so bsky's relay crawls it in the one |
| 249 | 263 | // monotonic subscribeRepos stream (FleetSequencer re-stamps a global seq). |
| … | … | @@ -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. | |
| 5 | import test from 'brittle' | |
| 6 | import { mkdtempSync } from 'node:fs' | |
| 7 | import { tmpdir } from 'node:os' | |
| 8 | import { join } from 'node:path' | |
| 9 | import Hyperswarm from 'hyperswarm' | |
| 10 | import createTestnet from '@hyperswarm/testnet' | |
| 11 | import { openCorestore } from 'atproto-store/lib/corestore.js' | |
| 12 | import { PeerRouter } from '../lib/peer-router.js' | |
| 13 | ||
| 14 | test('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 | }) |