// bridge.js — asking a bridge to carry this identity onto a network it does not speak.
//
// ⛔ THIS DAEMON LEARNS NOTHING ABOUT THE NETWORK. `test/boundary.test.js` forbids naming a
// projection here, from hyper-social #61 §10 — "the moment the daemon imports the projection the
// dependency has been inverted". So this asks a bridge for a NAME and records the answer; what that
// name means, and what the bridge does with the feed, is the bridge's business. The same verbs serve
// any bridge, which is the point: one ceremony, not one per network.
//
// ⛔ WHY THE SWARM AND NOT HTTP. This package has no outbound HTTP at all and should not gain one:
// adding a client would also inherit the bare-https defect where an idle socket is never reaped
// (24.3 GB, two OOMs). Both ends are already swarm members. A bridge is a service peers connect to.
//
// ⛔ THE BYTES ARE NOT DEFINED HERE. They come from favela-identity, which the bridge also uses, for
// a reason worth stating: if the two sides disagreed by one byte the verifier would return null and
// the bridge would answer "proof does not attest that identity" — indistinguishable from a forged
// proof. There is no error that says "we disagree about the format".
import ProtomuxRPC from 'protomux-rpc'
import c from 'compact-encoding'
import b4a from 'b4a'
import { signAttested } from 'favela-identity/lib/attestation.js'
import { claimBytes, releaseBytes, frontBytes, collectBytes } from 'favela-identity/lib/bridge-claim.js'
export const PROTOCOL = 'favela/bridge-register/1'
const HEX64 = /^[0-9a-f]{64}$/i
const DEFAULT_TIMEOUT = 20_000
/**
* Open an RPC channel to a bridge, by its key.
*
* ⛔ THE KEY, NOT A HOSTNAME. A topic derived from a name is squattable, and a squatter cannot steal
* a claim — root and feed key are public and the proof is bound to a nonce — but it CAN answer
* "registered!" and leave a node recording a name that does not exist. Connecting to a key means the
* answer came from the holder of that key or from nobody.
*
* ⚠ `{ client: true }` on our side and the bridge joins `{ server: true, client: true }`: two
* firewalled hosts only punch when both send outbound, which hyper-proto-peer measured — symmetric
* connects in ~2s, asymmetric never open at all.
*/
export async function connectBridge (swarm, bridgeKey, { timeoutMs = DEFAULT_TIMEOUT } = {}) {
if (!swarm) throw new Error('this node has no swarm, so it cannot reach a bridge')
const key = String(bridgeKey || '').replace(/^bridge:/, '').toLowerCase()
if (!HEX64.test(key)) throw new Error('a bridge key is 64 hex characters — check what you pasted')
const topic = b4a.from(key, 'hex')
const existing = [...swarm.connections].find((conn) =>
conn.remotePublicKey && b4a.equals(conn.remotePublicKey, topic))
if (existing) return { rpc: rpcOver(existing), close: () => {} }
const discovery = swarm.join(topic, { client: true, server: false })
const conn = await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
swarm.off('connection', onconn)
reject(new Error('the bridge did not answer within ' + Math.round(timeoutMs / 1000) + 's — it may be offline'))
}, timeoutMs)
const onconn = (conn) => {
if (!conn.remotePublicKey || !b4a.equals(conn.remotePublicKey, topic)) return
clearTimeout(timer)
swarm.off('connection', onconn)
resolve(conn)
}
swarm.on('connection', onconn)
discovery.flushed().catch(() => {})
})
// ⚠ The discovery is left joined for the life of the node rather than torn down per request: a
// claim is usually followed by the bridge holding this feed, and dropping the topic between the
// two would close the connection the bridge is about to use.
return { rpc: rpcOver(conn), close: () => {} }
}
// ⛔ ONE CHANNEL PER CONNECTION, cached. Protomux keys a channel by protocol on a given stream, so
// opening a second one for the same protocol is a DUPLICATE CHANNEL — and that is what a person sees
// when they simply try again after a failed attempt: the first try leaves the connection open, the
// retry builds a fresh ProtomuxRPC over it, and the retry fails for a reason that has nothing to do
// with what they were doing.
//
// ⚠ A WeakMap, so a connection that goes away takes its channel with it rather than pinning it.
const channels = new WeakMap()
const rpcOver = (conn) => {
const had = channels.get(conn)
if (had) return had
const rpc = new ProtomuxRPC(conn, { protocol: PROTOCOL, valueEncoding: c.json })
channels.set(conn, rpc)
// Dropped when the connection does, or a reconnect would hand back a channel on a dead stream.
conn.once('close', () => { try { channels.delete(conn) } catch {} })
return rpc
}
/**
* The claim a peer sends. Pure — no network, no clock — so what gets signed is testable on its own.
*
* ⛔ `cred` is this device's credential, the same one that signs room writes. The bridge verifies the
* chain from the identity ROOT down to this device, so a device that was never attested cannot
* register a name, which is the correct answer: it cannot write to the feed either.
*/
export function buildClaim ({ nonce, username, feedKey, root, cred, invite = null }) {
if (!root) throw new Error('this node cannot sign yet — link this device to its identity first')
const proof = signAttested(claimBytes({ nonce, username, feedKey }), cred)
const req = { username, root, feedKey, nonce, proof }
if (invite) req.invite = invite
return req
}
/** The release a peer sends. Separate bytes, so a claim proof can never be repurposed to burn a name. */
export function buildRelease ({ nonce, username, root, cred }) {
if (!root) throw new Error('this node cannot sign yet — link this device to its identity first')
return { username, root, nonce, proof: signAttested(releaseBytes({ nonce, username }), cred) }
}
/**
* Ask a bridge to CARRY an identity this node holds, rather than to name one.
*
* ⚠ `id` is opaque to this daemon on purpose. It is a credential this node holds on some other
* network; what it means there is the bridge's business, and boundary.test.js is what keeps that
* true.
*/
export function buildFront ({ nonce, id, root, cred }) {
if (!root) throw new Error('this node cannot sign yet — link this device to its identity first')
return { id, root, nonce, proof: signAttested(frontBytes({ nonce, id }), cred) }
}
/**
* Ask a bridge for what it OBSERVED while this node was not reachable.
*
* ⛔ WHY A PULL. A handset is not addressable — measured on ap.7rnx.net, a registered peer's outbox
* hangs for the full timeout because the device holding that feed does not answer. A bridge with
* something to tell us has nowhere to send it, so the direction has to be ours: we connect, we ask,
* we decide what to keep. It is also the only direction the model allows — a bridge is not a writer
* on this feed and must never author into it (hyper-social #61 §4).
*
* ⛔ THE CURSOR IS SIGNED, so it cannot be wound back in flight. That is also why a page needs its
* own challenge: the nonce is single-use, and the bytes name the cursor this particular page asked
* for. Collecting N pages is N challenges, deliberately.
*
* ⚠ THIS BUILDS THE REQUEST AND NOTHING ELSE. What arrives is a list of things OTHER people did, on
* a network this daemon deliberately knows nothing about — boundary.test.js forbids naming one here.
* Deciding what those rows mean, and writing them down, is the caller's job.
*/
export function buildCollect ({ nonce, username, since = 0, root, cred }) {
if (!root) throw new Error('this node cannot sign yet — link this device to its identity first')
return { username, root, since, nonce, proof: signAttested(collectBytes({ nonce, username, since }), cred) }
}
export default { connectBridge, buildClaim, buildRelease, buildFront, buildCollect, PROTOCOL }
// ── which bridges carry this node ────────────────────────────────────────────────────────────────
//
// ⛔ RELEASING NEEDS THE KEY AGAIN, and the name alone does not contain it. `name@host` says which
// bridge in human terms; connecting needs the 64-hex key, and asking a person to paste it a second
// time — to perform the one irreversible act in this flow — is how the wrong bridge gets asked.
//
// ⚠ LOCAL, not in `reach`. `reach` says where this identity PUBLISHES, which is a statement about
// the identity and replicates with it. Which key this node dialled to arrange that is operational
// detail: another device holding the same identity would resolve the same bridge by its own record.
import { join } from '#node:path'
import fs from '#node:fs'
const bridgesPath = (storage) => join(storage, 'bridges.json')
const readBridges = (storage) => {
try { return JSON.parse(fs.readFileSync(bridgesPath(storage), 'utf8')) } catch { return {} }
}
/** Remember that `acct` is carried by `bridge`. Best-effort: losing this costs a re-paste, not a name. */
export function rememberBridge (storage, acct, bridge) {
if (!acct || !bridge) return
const all = readBridges(storage)
all[acct] = String(bridge).replace(/^bridge:/, '').toLowerCase()
try { fs.writeFileSync(bridgesPath(storage), JSON.stringify(all, null, 2) + '\n') } catch {}
}
/** Which bridge carries `acct`, or null. */
export function bridgeFor (storage, acct) {
return (acct && readBridges(storage)[acct]) || null
}
export function forgetBridge (storage, acct) {
const all = readBridges(storage)
if (!(acct in all)) return
delete all[acct]
try { fs.writeFileSync(bridgesPath(storage), JSON.stringify(all, null, 2) + '\n') } catch {}
}
// ── the observation feed a bridge vouched for (hyper-social #82, I1 S4) ─────────────────────────────────────
//
// ⛔ VERIFIED AGAINST THE BRIDGE KEY THIS NODE CLAIMED WITH (operator decision O-2 b). A bridge writes what the
// outside world did to this user into a feed IT founded, and tells the node which feed and which founder root in
// the claim reply, signed with its swarm key. The grant is kept only if that signature holds for this user and
// generation; a node never holds — so never renders — a feed its bridge did not vouch for.
//
// ⚠ LOCAL, like bridges.json: which feed a bridge keeps for this node is operational detail, not something the
// identity publishes. Revocation is local too: forget the bridge, forget its feed.
import { verifyObserver } from 'favela-identity/lib/observer-grant.js'
const observersPath = (storage) => join(storage, 'observers.json')
const readObservers = (storage) => {
try { return JSON.parse(fs.readFileSync(observersPath(storage), 'utf8')) || {} } catch { return {} }
}
const writeObservers = (storage, all) => {
try { fs.writeFileSync(observersPath(storage), JSON.stringify(all, null, 2) + '\n') } catch {}
}
/** Every remembered bridge, as [acct, bridgeKeyHex]. */
export function bridges (storage) { return Object.entries(readBridges(storage)) }
/**
* Keep the observer grant from a claim reply, if `bridgeKey` signed it for this user. → the stored record, or null.
*/
export function acceptObserver (storage, bridgeKey, body) {
if (!body || !body.observer || !body.acct || !body.username) return null
const key = String(bridgeKey || '').replace(/^bridge:/, '').toLowerCase()
const o = body.observer
const grant = { username: body.username, generation: body.generation, observationFeed: o.observationFeed, observerRoot: o.observerRoot }
if (!verifyObserver(grant, o.sig, key)) return null
const rec = {
acct: body.acct,
bridge: key,
username: body.username,
generation: Number(body.generation) | 0,
observationFeed: String(o.observationFeed).toLowerCase(),
observerRoot: String(o.observerRoot).toLowerCase()
}
const all = readObservers(storage)
all[body.acct] = rec
writeObservers(storage, all)
return rec
}
/** Every observation feed this node holds on a bridge's word. */
export function observers (storage) { return Object.values(readObservers(storage)) }
export function forgetObserver (storage, acct) {
const all = readObservers(storage)
if (!(acct in all)) return
delete all[acct]
writeObservers(storage, all)
}
/**
* Which [acct, bridgeKey] pairs a device should ask for an observation grant: its own bridges.json PLUS the bridge
* the identity's reach names (replicated, so every device of the identity knows it), minus names already granted.
*/
export function claimTargets (storage, reach = {}) {
const granted = new Set(observers(storage).map((o) => o.acct))
const out = new Map()
for (const [acct, key] of bridges(storage)) if (acct && key) out.set(acct, String(key).toLowerCase())
if (reach && reach.fediverse && reach.fediverseBridge && !out.has(reach.fediverse)) {
out.set(reach.fediverse, String(reach.fediverseBridge).toLowerCase())
}
return [...out.entries()].filter(([acct]) => !granted.has(acct))
}