OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / register-rpc.js

146 lines · javascript ref:
// 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 }