OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge / pds / lib / membrane.js

328 lines · javascript ref:
// The atproto MEMBRANE as a module — a stateless bridge (Model B). Holds NO key, NO account, NO
// custodial storage. It fronts self-custody hyper-proto-peers (cfg.frontedDids) discovered over the
// DHT, verifies each against its DID key, and serves them keylessly to bsky. All
// identity/content/signing lives in the peers (hyper-proto-peer).
//
// ⛔ WHY A MODULE. Until 0.9.52 this was pds-serve.js's top level: it bound the port, installed signal
// handlers and called process.exit, so the membrane could only be stopped by ending its process. The
// favela gateway (hyper-social #80) runs it beside ap-bridge in ONE process, so it has to be startable
// and stoppable as a unit. startMembrane() does exactly what pds-serve.js did and returns:
//   - `steps`: the ordered stop sequence, as [name, fn] pairs, so the caller's bounded shutdown
//     (lib/shutdown.js) times and reports each one;
//   - `handlers`: the control verbs, for whichever socket serves them (lib/control.js);
//   - `port`, `swarmKey`, `peers`, `relay`, `status()`.
// Nothing here exits the process or installs a signal handler. pds-serve.js is the standalone wrapper.

import './bare-globals.js' // FIRST — polyfills TextEncoder/TextDecoder under Bare
import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import Hyperswarm from 'hyperswarm'
import b4a from 'b4a'
import { openCorestore } from 'atproto-store/lib/corestore.js'
import { loadOrCreateSwarmKeyPair } from './swarm-keypair.js'
import { membraneHandlers } from './control.js'
import { FleetRelay } from './fleet-relay.js'
import { DidPeerRouter } from 'atproto-did-peer'
import { resolvePlcVerifyKey } from './verify-key.js'
import { discoveryTopic } from 'atproto-identity/lib/discovery.js'

// The HTTP membrane is transport-specific: bare-http1/bare-ws under Bare, node http+ws
// otherwise. (bsky-interop edge only; P2P sync is Hypercore, not this.)
const isBare = typeof Bare !== 'undefined'
const { createPdsServer } = await import(isBare ? './server-bare.js' : './server.js')

// Bare has no global `fetch`; PLC resolution (verify a fronted peer's DID key before serving
// it — verify-key.js resolvePlcVerifyKey) needs one. Install the WHATWG-standard bare-fetch
// global so it works on the prod (bare) runtime. Without this, front() throws "fetch is not
// defined" and NO peer is fronted — silently dropping wj62. Node already has global fetch.
if (isBare) await import('bare-fetch/global')

const defaultLog = (o) => console.log(JSON.stringify(o))

/**
 * @param {object} cfg       loadConfig()'s shape: serverDid, userDomain, frontedDids, feedRegistries,
 *                           crawlRelay, crawlHostname, port, bind, storage
 * @param {object} [opts]
 * @param {Function} [opts.log]        one object per line
 * @param {Array}    [opts.bootstrap]  DHT bootstrap; tests pass [] to stay off the public DHT
 */
export async function startMembrane (cfg, { log = defaultLog, bootstrap = null } = {}) {
  // The front-store holds the aggregate firehose (relay) + replicated peer cores. It is content,
  // NOT accounts — no ACCOUNT key material ever lives here. (The swarm keypair below is the one
  // piece of key material the membrane does keep: a transport/DHT identity that never signs a repo
  // commit and holds no account. Amended from "no key material ever" — see swarm-keypair.js.)
  const frontStore = openCorestore(path.join(cfg.storage, 'front-store'))
  // PERSISTENT swarm identity. A random per-boot keypair (plain `new Hyperswarm()`) made the bridge's
  // Noise public key unstable, so hyper-proto-peer's PEER_WRITERS allowlist — which gates apply-write,
  // delegate and get-service-auth on the CLIENT's Noise key — could never be populated: any entry
  // authorised exactly one boot. Stable key ⇒ the bridge is allowlistable.
  const swarmKeyPair = loadOrCreateSwarmKeyPair(cfg.storage)
  const swarm = new Hyperswarm(bootstrap ? { keyPair: swarmKeyPair, bootstrap } : { keyPair: swarmKeyPair })
  const swarmKey = b4a.toString(swarmKeyPair.publicKey, 'hex')
  // Logged at BOOT, deliberately: this is the value an operator must paste into each DID's `writers`
  // array. Without it, populating PEER_WRITERS means fishing a key out of a peer's connection logs and
  // hoping it was ours. Public key only — the secret stays in the file.
  log({ msg: 'swarm identity', publicKey: swarmKey })
  // Durable emitted-seq floor — kept in a plain file BESIDE the front-store (survives a corestore
  // reset), so subscribeRepos seq stays monotonic across redeploys and can be lifted above a relay's
  // stored cursor for recovery (see FleetRelay seqBase / the getHostStatus recovery).
  const seqBaseFile = path.join(cfg.storage, 'relay-seq-base')
  let seqBase = 0
  try { seqBase = Number(fs.readFileSync(seqBaseFile, 'utf8').trim()) || 0 } catch {}
  const persistSeqBase = (n) => { try { fs.mkdirSync(cfg.storage, { recursive: true }) } catch {}; fs.writeFileSync(seqBaseFile, String(n)) }
  // The relay aggregates each fronted peer's signed firehose into one monotonic subscribeRepos
  // stream, so bsky's relay crawls the whole set in one shot (the egress membrane).
  const relay = await new FleetRelay(frontStore.get({ name: 'peer-relay-firehose', valueEncoding: 'json' }), { seqBase, onSeqBase: persistSeqBase }).ready()
  // PERSISTED handle->did map (survives restart — an offline blind-relayed peer, e.g. ashatron, can't
  // re-teach its handle at boot, so an in-memory-only map left it unresolvable after a restart).
  const handleMapFile = path.join(cfg.storage, 'handle-map.json')
  let handles = {}
  try { handles = JSON.parse(fs.readFileSync(handleMapFile, 'utf8')) || {} } catch {}
  const persistHandles = (obj) => { try { fs.mkdirSync(cfg.storage, { recursive: true }) } catch {}; fs.writeFileSync(handleMapFile, JSON.stringify(obj)) }
  // The canonical consumer primitive (atproto-did-peer DidPeerRouter — unified across bridge/favela in
  // EXTRACTION-PLAN Phase 1). Owns-swarm mode: it auto-wires onconnection + joins each fronted topic in its
  // ctor (via `join`) + runs the pull-only refresh/failover timer. `rpc: true` opens one protomux-rpc per
  // connection for the peer-signs-over-RPC write/session/blob path. `resolveVerifyKey` fronts a DID with no
  // pre-seeded key via PLC. `relay` folds the freshest co-owner's firehose into the aggregate subscribeRepos.
  const peers = new DidPeerRouter(frontStore, {
    swarm,
    join: (did) => { try { swarm.join(discoveryTopic(did), { server: true, client: true }); swarm.flush().catch(() => {}) } catch {} },
    fronted: cfg.frontedDids,
    relay,
    resolveVerifyKey: resolvePlcVerifyKey,
    rpc: true,
    handles,
    onHandles: persistHandles
  })

  // ⛔ THE SWARM DOOR onto fronting. `allowFront()` only UN-DENIES; a DID enters the fronted set
  // through `add()`, reachable from config at boot or the owner-gated control socket — so being
  // carried was an operator action and a peer had no way to ask. This is that way.
  //
  // ⚠ PERSISTED, exactly like the handle map above it: `frontedDids` is CONFIG, and what peers asked
  // for is not. A restart that silently unfronted everyone who asked would make the door a lie.
  const requestedFile = path.join(cfg.storage, 'fronted-requested.json')
  const readRequested = () => { try { return JSON.parse(fs.readFileSync(requestedFile, 'utf8')) } catch { return {} } }
  const requested = readRequested()
  // ⛔ The handle goes back in too, or a restart forgets the name it granted and
  // `<handle>/.well-known/atproto-did` stops answering for someone already published on-chain.
  for (const [id, r] of Object.entries(requested)) {
    try { peers.add(id, r && r.handle ? { handle: r.handle } : {}) } catch {}
  }
  if (Object.keys(requested).length) log({ msg: 'refronted on boot', count: Object.keys(requested).length })

  let bridgeKey = null
  try {
    const { serveFronting, Admission } = await import('./fronting.js')
    const door = serveFronting({
      swarm,
      peers,
      admission: new Admission(),
      policy: process.env.PDS_FRONT_POLICY || 'open',
      // What the membrane OFFERS: the domain it grants names under, and the endpoint a DID must point
      // at. A peer needs both before it commits, because accepting is a permanent append to a global log.
      userDomain: cfg.userDomain,
      // ⛔ DERIVED FROM serverDid, not invented. `did:web:<host>` IS this membrane's public identity —
      // config.js already takes crawlHostname's default from exactly that host part, so using any other
      // source here would be a second answer to "where is this membrane" that could drift from the first.
      pdsUrl: 'https://' + (/^did:web:/.test(cfg.serverDid) ? cfg.serverDid.slice('did:web:'.length) : 'pds.7rnx.net'),
      onFronted: ({ id, root, handle }) => {
        const all = readRequested()
        all[id] = { root, handle: handle || null, at: Date.now() }
        fs.mkdirSync(cfg.storage, { recursive: true })
        fs.writeFileSync(requestedFile, JSON.stringify(all, null, 2))
      },
      log
    })
    await door.ready()
    bridgeKey = door.key
    // ⛔ PRINTED: it is what a peer needs in order to find this bridge at all, and a key written
    // nowhere is the favela-android device-key mistake one layer over.
    log({ msg: 'fronting open over the swarm', bridgeKey: door.key })
  } catch (err) {
    // Non-fatal: the membrane serving what it already fronts is worth more than one that refuses to
    // boot because a second door would not open.
    log({ msg: 'swarm fronting unavailable', error: (err && err.message) || String(err) })
  }

  // 1. bsky HTTP+WS membrane — keyless per-DID routing to the fronted peers.
  // XRPC methods and WebSocket paths another role of this process serves through the membrane (the
  // gateway's lens). Empty until a role registers; did.json advertises #bsky_fg once a skeleton is served.
  const extensions = { xrpc: new Map(), ws: new Map(), http: new Map() }
  let rooms = null
  const server = createPdsServer({ peers, serverDid: cfg.serverDid, availableUserDomains: [cfg.userDomain], extensions })
  await new Promise((resolve, reject) => {
    server.once('error', reject)
    server.listen(cfg.port, cfg.bind, resolve)
  })
  const port = server.address().port

  // 2. Warm the fronts in the BACKGROUND — never block the HTTP/UDS listen or the readiness probe.
  // A peer offline at boot (or one that hasn't signed its first commit yet) must not delay the bridge
  // coming up: awaiting fronts here blew past the 30s UDS readiness window → supervisor restart loop.
  // The router already joined every fronted topic in its constructor, so peers connect + get
  // identified in the background; front() folds each into the relay as it warms, and retries on demand.
  for (const did of cfg.frontedDids) {
    peers.front(did)
      .then(() => log({ msg: 'fronting', did }))
      .catch((e) => log({ msg: 'front failed (will retry on demand)', did, error: e.message }))
  }

  // 3. Seq guard — the half of the seq-floor mechanism that was missing: something that NOTICES.
  //
  // FleetRelay.setSeqBase + the durable `relay-seq-base` file (above) have always been able to lift our
  // emitted seq past a relay's stored cursor. Nothing ever called them. Recovery required a human to know
  // to read com.atproto.sync.getHostStatus and hand-call the `set-seq-base` UDS RPC — so on 2026-07-31 the
  // bridge sat parked at cursor 9834 against our 79 frames for ~20 hours, with every local surface healthy.
  // A mechanism nothing invokes is not a mechanism; this is the trigger.
  //
  // Runs once at boot and every 15 minutes. Read-only unless the arithmetic says the relay can never reach
  // us. OUTWARD-FACING, so it inherits the same opt-in gate as the reactor's requestCrawl below.
  // ⛔ The timer is CLEARED by a stop step: `unref` alone let it outlive a stopped membrane, and a
  // restart in the same process would then run two guards.
  let seqGuardTimer = null
  let seqGuardSoon = null
  // What the last pass saw and did, for `metrics`; and the last self-heal, for its rate limit.
  const guard = { last: null, lastAt: null, lastHealAt: 0, heals: 0, configured: !!(cfg.crawlRelay && cfg.crawlHostname) }
  if (cfg.crawlRelay && cfg.crawlHostname) {
    const { runSeqGuard } = await import('./seq-guard.js')
    const crawl = async () => {
      const res = await fetch(`${cfg.crawlRelay.replace(/\/$/, '')}/xrpc/com.atproto.sync.requestCrawl`, {
        method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ hostname: cfg.crawlHostname })
      })
      if (!res.ok) throw new Error(`requestCrawl ${res.status}`)
    }
    const pass = () => runSeqGuard({
      relay: peers.relay,
      relayBase: cfg.crawlRelay,
      hostname: cfg.crawlHostname,
      requestCrawl: crawl,
      log,
      lastHealAt: guard.lastHealAt,
      now: Date.now()
    }).then((r) => {
      guard.last = r
      guard.lastAt = Date.now()
      if (r && r.healedAt) guard.lastHealAt = r.healedAt
      if (r && r.healed) guard.heals++
    }).catch((e) => log({ msg: 'seq-guard pass failed', error: e && e.message }))
    pass() // boot pass, backgrounded — must never delay listen or the readiness probe
    // ⛔ A SECOND PASS SHORTLY AFTER BOOT. A restart drops the relay's subscribeRepos connection, and the
    // relay only marks us offline after its reconnects fail — after the boot pass has already seen
    // "active". Without this an OTA could leave bsky disconnected for a full interval.
    seqGuardSoon = setTimeout(pass, 2 * 60 * 1000)
    if (seqGuardSoon.unref) seqGuardSoon.unref()
    seqGuardTimer = setInterval(pass, 15 * 60 * 1000)
    if (seqGuardTimer.unref) seqGuardTimer.unref()
  } else {
    log({ msg: 'seq-guard OFF (no crawlRelay/crawlHostname) — a relay that parks us will stay parked' })
  }

  // 4. Feed-registry reactor (community-ux.md §111) — OFF unless cfg.feedRegistries is set. Watch each keyless
  // FeedRegistry (the durable P2P allow-set) and, per feed entry, front its home DID + requestCrawl once, with
  // no per-community config and no restart. Gated + guarded: favela-community is dynamically imported only when
  // the feature is on, and any failure is contained to this feature (never blocks the membrane). requestCrawl
  // is OUTWARD-FACING (asks bsky's relay to crawl us) and stays inert unless cfg.crawlRelay is configured.
  if (cfg.feedRegistries && cfg.feedRegistries.length) {
    ;(async () => {
      try {
        const { FeedRegistries } = await import('favela-community')
        const { makeMembraneReactor } = await import('./membrane-reactor.js')
        // Live federation call: POST com.atproto.sync.requestCrawl { hostname } to the bsky relay. Off (null)
        // → the reactor still fronts, but fires no live crawl. Built here (not in the pure reactor) so the
        // outward-facing side-effect is confined to the wiring + explicitly configured.
        const requestCrawl = (cfg.crawlRelay && cfg.crawlHostname)
          ? async () => {
              const res = await fetch(`${cfg.crawlRelay.replace(/\/$/, '')}/xrpc/com.atproto.sync.requestCrawl`, {
                method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ hostname: cfg.crawlHostname })
              })
              if (!res.ok) throw new Error(`requestCrawl ${res.status}`)
            }
          : null
        if (!requestCrawl) log({ msg: 'feed-registry reactor: requestCrawl OFF (no crawlRelay/crawlHostname) — fronting only' })
        const reconcile = makeMembraneReactor({
          addFront: (did) => peers.add(did),
          front: (did) => peers.front(did),
          requestCrawl,
          log: (m) => console.log(m)
        })
        const feedRegistries = new FeedRegistries(frontStore, swarm)
        for (const regKey of cfg.feedRegistries) {
          await feedRegistries.watch(regKey, (feeds) => reconcile(feeds))
          log({ msg: 'watching feed registry', registry: regKey.slice(0, 12) + '…' })
        }
      } catch (e) {
        log({ msg: 'feed-registry reactor setup failed (membrane unaffected)', error: e.message })
      }
    })()
  }

  log({
    msg: 'membrane listening',
    http: `${cfg.bind}:${port}`,
    swarmKey,
    serverDid: cfg.serverDid,
    frontedDids: cfg.frontedDids,
    feedRegistries: cfg.feedRegistries
  })

  return {
    port,
    swarmKey,
    bridgeKey,
    peers,
    relay,
    handlers: membraneHandlers({ peers }),
    status: () => ({ ok: true, membrane: true, port, fronted: peers.list() }),
    extensions,
    // A community room, READ-ONLY, on this membrane's own store and swarm — the lens's roster (who is in
    // the feed). Opened with the membrane's OWN favela-community import, exactly as the feed-registry
    // reactor does, so a corestore never crosses module copies. Lazily created once; closed by a stop step.
    communityRooms: async () => {
      if (!rooms) {
        const { CommunityRooms } = await import('favela-community')
        rooms = new CommunityRooms(frontStore.namespace('comm'), swarm)
      }
      return rooms
    },
    // The relay guard's last pass and heals, for `metrics`. Read-only.
    guard: () => ({ ...guard }),
    // What the gateway's panel and CLI show for this role, from local state only — no network call.
    // Per fronted DID: the served co-owner's handle and head rev, how many co-owner fronts are cached and
    // how many are connected now, when its firehose last appended, rival lines refused and keys denied.
    metrics: () => {
      const d = peers.diagnose()
      const dids = d.fronted.map((did) => {
        const fronts = d.fronts.filter((f) => f.did === did)
        const served = fronts.find((f) => f.served) || fronts[0] || null
        return {
          did,
          handle: served ? served.handle : null,
          rev: served ? served.rev : null,
          fronts: fronts.length,
          live: fronts.filter((f) => f.live).length,
          lastAppendAt: (d.lastAppendAt && d.lastAppendAt[did]) || null,
          forkRejected: fronts.reduce((n, f) => n + (f.forkRejected || 0), 0),
          denied: (d.denied[did] || []).length
        }
      })
      return { dids, relay: { emitted: relay.length, seqBase: relay.seqBase }, guard: { ...guard } }
    },
    // The stop sequence, unchanged in order from pds-serve.js (control is the caller's, so it is not
    // here), plus the seq-guard timer.
    steps: [
      ['seq-guard', () => { if (seqGuardTimer) clearInterval(seqGuardTimer); if (seqGuardSoon) clearTimeout(seqGuardSoon) }],
      ['community-rooms', async () => { if (rooms && typeof rooms.close === 'function') await rooms.close() }],
      ['peers', () => peers.close()],
      ['relay', () => relay.close()],
      ['swarm', () => swarm.destroy()],
      ['server', () => new Promise((resolve) => server.close(resolve))],
      // Release the front-store Corestore fd-lock last so a redeploy can reopen.
      ['frontStore', () => frontStore.close()]
    ]
  }
}

export default startMembrane