// register-rpc.js — asking this bridge for a name over the SWARM.
//
// ⛔ WHY NOT HTTP. favela-peer has no outbound HTTP at all — measured, not assumed: no bare-fetch, no
// bare-https, nothing in `lib/`. Giving it one to register a name would add a client, and with it the
// bare-https defect where an idle socket is never reaped (24.3 GB and two OOMs). Both sides already
// speak the swarm, and this bridge is already a member of it — `feed-holder.js` runs one to hold
// registered peers' feeds, and its own header says a bridge "is a service peers CONNECT TO".
//
// ⛔ THE SAME ROOM, A SECOND DOOR. Every rule lives behind this file — admission in `admit.js`,
// collection in `collect.js`; this file is transport and nothing else. A copy of the claim flow here
// would be the failure `favela-markdown/lib/reach.js` names — written twice, and the second one
// eventually written wrong.
//
// ⛔ AUTHORISATION IS THE CHAIN PROOF, NOT THE CONNECTION. hyper-proto-peer gates `apply-write` on
// `conn.remotePublicKey` against an allowlist because it relays for known callers. A bridge accepts
// requests from ANYONE — that is what an open bridge is — so what must be proved is WHICH IDENTITY,
// not which socket. The swarm key says which connection; `readAttested` says who. Do not substitute
// one for the other.
import ProtomuxRPC from 'protomux-rpc'
import Protomux from 'protomux'
import c from 'compact-encoding'
import b4a from 'b4a'
export const PROTOCOL = 'favela/bridge-register/1'
/**
* The verbs, as plain async functions. Exported separately from the transport so they can be tested
* without a DHT — the wiring below is thin enough to read, and the rules are not in it.
*/
export function registerHandlers (deps) {
return {
/** What a peer asks BEFORE trying, so a refusal is never a surprise. */
policy: async () => ({
policy: deps.registrations ? deps.registrations.policy : null,
host: (deps.config && deps.config.host) || null,
proof: !!deps.admission
}),
/** ⛔ Single-use, and required: readAttested has no clock, so replay is caught here or nowhere. */
challenge: async () => {
if (!deps.admission) throw new Error('registration is not configured on this bridge')
return deps.admission.challenge()
},
claim: async (params = {}) => {
const { admitClaim } = await import('./admit.js')
return admitClaim(deps, params)
},
release: async (params = {}) => {
const { admitRelease } = await import('./admit.js')
return admitRelease(deps, params)
},
/**
* What the fediverse did to this peer's records while it was not looking.
*
* ⛔ THE REASON THIS DOOR HAS TO EXIST. A handset is not reachable — measured on this very
* bridge, `/ap/users/fleeky/outbox` hangs for the full timeout because the phone holding that
* feed does not answer. A push would have nowhere to go. So the peer PULLS, on its own schedule,
* over the connection it already opens to register.
*
* ⚠ Paged and cursored rather than "everything since I last asked": the bridge keeps no per-peer
* read position, because a peer's second device would silently move the first one's.
*/
collect: async (params = {}) => {
const { admitCollect } = await import('./collect.js')
return admitCollect(deps, params)
}
}
}
/**
* Serve those verbs to anyone who connects on this bridge's own key.
*
* ⛔ THE TOPIC IS THE BRIDGE'S SWARM KEY, joined `{ server: true, client: true }`. Both flags are
* load-bearing and hyper-proto-peer measured why: a member that joins `client: false` never sends
* outbound holepunch packets, so its NAT mapping never opens and a peer punching from the other side
* is dropped. A NAT'd handset registering with a NAT'd bridge host is exactly that case — symmetric
* connects in ~2s, asymmetric never open at all.
*
* ⚠ The key is what a peer needs in order to find this bridge, so it is RETURNED and should be
* published somewhere a person can read it.
*/
export function serveRegistration ({ swarm, log = () => {}, ...deps }) {
if (!swarm) throw new Error('serveRegistration: a swarm is required')
// The swarm keypair signs observer grants (admit.js): its public key IS the bridgeKey a peer claimed against.
const handlers = registerHandlers({ bridgeKeyPair: swarm.keyPair || null, ...deps })
const onconnection = (conn) => { attachRegistration(conn, handlers) }
swarm.on('connection', onconnection)
const topic = swarm.keyPair.publicKey
const disc = swarm.join(topic, { server: true, client: true })
return {
key: b4a.toString(topic, 'hex'),
ready: () => disc.flushed(),
close: () => { try { swarm.off('connection', onconnection) } catch {} }
}
}
/**
* Serve the registration verbs on one connection.
*
* ⛔ AND KEEP SERVING A PEER THAT ASKS LATE (2026-10-14). A peer whose feed this bridge holds stays connected for
* hours. Our channel opens the moment the connection appears; the peer has none yet, so ITS protomux rejects that
* open (_requestSession → _rejectSession) and ours closes. When the user then presses "claim", the peer's open met
* no channel and no pair() handler here and was rejected too — "channel closed", every time, until the connection
* dropped (measured on a phone). So the responder is (re)created on demand: once now, and from pair() whenever the
* peer opens and no live channel remains. The same idiom as atproto-room's presence channel.
*/
export function attachRegistration (conn, handlers) {
let mux
try { mux = Protomux.from(conn) } catch { return null }
let live = null
const open = () => {
if (live && !live.closed) return live
let rpc
try {
rpc = new ProtomuxRPC(conn, { protocol: PROTOCOL, valueEncoding: c.json })
} catch { return null }
for (const [name, fn] of Object.entries(handlers)) {
rpc.respond(name, async (params) => {
try {
return await fn(params || {})
} catch (err) {
// ⚠ Answered, never thrown across the wire as a dead channel: a peer that gets no reply
// cannot tell a refusal from a bridge that is down, and those need different actions.
return { status: 500, body: { error: (err && err.message) || String(err) } }
}
})
}
rpc.on('close', () => { if (live === rpc) live = null })
live = rpc
return rpc
}
const first = open()
try { mux.pair({ protocol: PROTOCOL }, () => { open() }) } catch {}
try { conn.once('close', () => { try { mux.unpair({ protocol: PROTOCOL }) } catch {} }) } catch {}
return first
}
export default { serveRegistration, registerHandlers, attachRegistration, PROTOCOL }