// 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