OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge / pds / lib / blind-relay.js

122 lines · javascript ref:
// BlindRelay — a KEYLESS availability seeder for self-custody hyper-proto-peers.
//
// Problem: the atproto-bridge fronts a peer only over a LIVE connection (announce + DID-key repo
// verify). A SINGLE peer that goes offline (e.g. ashatron on a laptop) has no one serving its cores,
// so it vanishes from bsky. This relay keeps an EXPLICITLY-ALLOWLISTED set of peers' cores online:
// it replicates each peer's repo + firehose Hypercores (by key) into a PERSISTENT corestore and
// RE-ANNOUNCES them on the peer's DID discovery topic, PUSHING an announce from config + live core
// lengths. When the origin is offline the bridge connects to the RELAY instead and fronts the
// last-synced signed state.
//
// BLIND by construction: it holds NO keys and serves ONLY the read surface (announce push + core
// replication). It NEVER answers apply-write / create-session / get-service-auth, so it cannot sign
// or forge — the bridge still verifies the served repo against the DID key (PeerFront.fromEntry), so
// a relay can only make a peer's OWN signed data available, never fake it. New signed commits still
// require the origin peer (or an always-on 2nd device). It is NOT a StateBase indexer.
//
// GATED: seeds ONLY the peers in the config allowlist (`seedPeers`), never "every possible peer".
// Multi-peer: one swarm PER did (each is a distinct virtual peer so the bridge opens one connection
// per did — the announce carries exactly one did per connection).

import b4a from 'b4a'
import c from 'compact-encoding'
import Hyperswarm from 'hyperswarm'
import Protomux from 'protomux'
import { RepoStore } from 'atproto-store/lib/repo-store.js'
import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
import { discoveryTopic } from 'atproto-identity/lib/discovery.js'

const ANNOUNCE_PROTOCOL = 'atproto/did-announce/v1' // MUST match hyper-proto-peer serve-membrane + PeerRouter

// One seeded peer = { did, handle?, repoKey, firehoseKey }. All keys are hex. (serverKey, if present in
// older config, is ignored — announce-push discovery needs only repoKey/firehoseKey, no directionality.)
function validateEntry (p) {
  if (!p || !p.did || !p.repoKey || !p.firehoseKey) {
    throw new Error('BlindRelay seedPeer requires { did, repoKey, firehoseKey } (hex)')
  }
}

export class BlindRelay {
  // corestore: ONE persistent Corestore (cores are global by key). swarmOpts: passed to each per-did
  // Hyperswarm (testnet bootstrap in tests). seedPeers: the ALLOWLIST (see validateEntry).
  constructor (corestore, { seedPeers = [], swarmOpts = null } = {}) {
    if (!corestore) throw new Error('BlindRelay: a corestore is required')
    this.corestore = corestore
    this._swarmOpts = swarmOpts
    this._allow = new Map() // did -> coords (the gate: only these dids are ever served)
    for (const p of seedPeers) { validateEntry(p); this._allow.set(p.did, { did: p.did, handle: p.handle || null, repoKey: p.repoKey, firehoseKey: p.firehoseKey }) }
    this._peers = new Map() // did -> { swarm, repo, seq, coords }
  }

  async ready () {
    for (const [did, coords] of this._allow) { try { await this._seed(did, coords) } catch (e) { console.error(JSON.stringify({ msg: 'blind-relay seed failed', did, error: e.message })) } }
    return this
  }

  // Seed ONE allowlisted peer: open its cores (so they persist + replicate) and re-announce on its
  // DID topic, answering get-coords from config. Cores sync from the origin whenever it connects and
  // stay served (persisted) when it doesn't.
  async _seed (did, coords) {
    // open the peer's repo + firehose cores BY KEY → corestore.replicate will sync them from the
    // origin when connected, and they persist on disk for offline serving.
    const repo = await new RepoStore(this.corestore, { key: b4a.from(coords.repoKey, 'hex') }).ready()
    const seq = await new FirehoseSequencer(this.corestore.get({ key: b4a.from(coords.firehoseKey, 'hex') })).ready()

    // FULL seeder: a sparse replica only holds blocks it happened to fetch, so it could serve NOTHING
    // once the origin is offline. Actively download EVERY block + keep following appends, so the relay
    // durably holds the complete repo + firehose and can serve them with the origin gone.
    const seedCore = (core) => {
      const pull = () => { try { core.download({ start: 0, end: -1 }) } catch {} }
      pull()
      core.on('append', pull)
    }
    seedCore(repo.core)
    seedCore(seq.core)

    const swarm = new Hyperswarm(this._swarmOpts || undefined)
    const onconn = (conn) => {
      // symmetric replication: pulls fresh blocks FROM the origin, and serves cached blocks TO the bridge.
      try { this.corestore.replicate(conn) } catch (e) { console.error(JSON.stringify({ msg: 'blind-relay replicate threw', did, error: e.message })) }
      // The ONLY thing we push: the announce (atproto/did-announce/v1, from the allowlist config +
      // live core lengths). We deliberately open NO write RPC — no apply-write / create-session /
      // get-service-auth / upload-blob — a relay is blind: it can make a peer's OWN signed data
      // available (bridge verifies against the DID key), never sign or forge.
      try {
        const chan = Protomux.from(conn).createChannel({ protocol: ANNOUNCE_PROTOCOL })
        if (chan) {
          const msg = chan.addMessage({ encoding: c.json, onmessage: () => {} })
          chan.open()
          const send = () => { try { msg.send({ did, handle: coords.handle, repoKey: coords.repoKey, firehoseKey: coords.firehoseKey, repoLength: repo.core.length, firehoseLength: seq.core.length }) } catch {} }
          send()
          const onapp = () => send()
          seq.core.on('append', onapp)
          conn.on('close', () => { try { seq.core.off('append', onapp) } catch {} })
        }
      } catch (e) { console.error(JSON.stringify({ msg: 'blind-relay announce threw', did, error: e.message })) }
    }
    swarm.on('connection', onconn)
    // server:true → the bridge (and bsky) reach us here when the origin is offline. client:true → we
    // also punch out to the origin to pull fresh blocks (firewalled↔firewalled needs both, like the peer).
    swarm.join(discoveryTopic(did), { server: true, client: true })
    await swarm.flush().catch(() => {})
    this._peers.set(did, { swarm, repo, seq, coords })
    console.log(JSON.stringify({ msg: 'blind-relay seeding', did, handle: coords.handle, repoLen: repo.core.length, firehoseLen: seq.core.length }))
  }

  // Observability: what we're seeding + how much we hold (served even when the origin is offline).
  status () {
    const out = []
    for (const [did, p] of this._peers) out.push({ did, handle: p.coords.handle, repoLength: p.repo.core.length, repoContiguous: p.repo.core.contiguousLength, firehoseLength: p.seq.core.length, firehoseContiguous: p.seq.core.contiguousLength })
    return { seededCount: out.length, peers: out }
  }

  async close () {
    for (const [, p] of this._peers) {
      try { await p.swarm.destroy() } catch {}
      try { await p.seq.close() } catch {}
      try { await p.repo.close() } catch {}
    }
    this._peers.clear()
  }
}