// fronting.js — asking this membrane to CARRY an identity, without an operator in the loop.
//
// ⛔ WHAT WAS MISSING. `allowFront()` only UN-DENIES a firehose key that `dropFront({forget})` denied;
// it does not allow-list anything. `_isCared(did)` is `_fronted || _peers || _opts`, and a DID enters
// `_fronted` through `add()` — reachable only from `cfg.frontedDids` at boot or from the owner-gated
// control socket. So being carried was an operator action on the bridge host, and a peer holding a
// perfectly good identity had no way to ask. That is the same gap ap-bridge had for names.
//
// ⛔ WHAT THE PROOF IS AND IS NOT FOR. This membrane is KEYLESS: it resolves a DID's verify key from
// PLC and refuses any commit that does not verify against it. So a caller naming somebody else's
// identity cannot make this bridge serve a forgery — it would serve that person's real, signed repo.
// The proof is therefore not what protects the identity; the signature chain already does. It bounds
// ABUSE: carrying an identity costs a topic join, a corestore namespace and replication for ever,
// and "anyone may spend that, anonymously, unboundedly" is not a policy anyone chose.
//
// ⚠ Which is why refusing is cheap and being wrong is cheap. An identity wrongly carried serves
// correct bytes to whoever asks; an identity wrongly refused just is not fronted here yet.
import ProtomuxRPC from 'protomux-rpc'
import c from 'compact-encoding'
import b4a from 'b4a'
import { Admission } from 'favela-identity/lib/admission.js'
// ⚠ THE SAME PROTOCOL STRING as ap-bridge's registration door, deliberately: it is one ceremony with
// one set of verbs, and a peer should not have to know which kind of bridge it is talking to before
// it can ask what that bridge does.
export const PROTOCOL = 'favela/bridge-register/1'
export { Admission }
/**
* The verbs, as plain async functions — testable without a DHT. The transport below is thin, and the
* rules are not in it.
*/
const HANDLE_NAME = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/
export function frontHandlers ({ peers, admission, onFronted = null, policy = 'open', userDomain = '.7rnx.net', pdsUrl = null, log = () => {} }) {
return {
// ⛔ THE OFFER, not just a yes/no. A peer needs to know the handle suffix and the endpoint BEFORE
// it commits, because accepting is a permanent append to a global log — it cannot be undone if
// the answer turns out to be something else.
policy: async () => ({ policy, proof: !!admission, userDomain, pdsUrl }),
challenge: async () => {
if (!admission) throw new Error('fronting is not configured on this bridge')
return admission.challenge()
},
/**
* ⚠ IDEMPOTENT. `add()` on an already-fronted identity returns the existing peer, and a peer
* that retries after a dropped connection must not be told it did something wrong.
*/
front: async (params = {}) => {
if (!peers) return { status: 503, body: { error: 'this bridge fronts nothing' } }
if (!admission) return { status: 503, body: { error: 'fronting is not configured on this bridge' } }
if (policy !== 'open') return { status: 403, body: { error: 'this bridge does not take requests' } }
const ok = admission.verifyFront({ nonce: params.nonce, id: params.id, root: params.root, proof: params.proof })
if (!ok.ok) return { status: 403, body: { error: ok.error } }
// ⛔ THE HANDLE IS GRANTED HERE, because the membrane owns the namespace under its domain and
// is the only thing that can answer `<handle>/.well-known/atproto-did`. A peer that chose its
// own handle would be claiming a name on somebody else's domain.
const name = String(params.name || '').trim().toLowerCase()
if (name && !HANDLE_NAME.test(name)) return { status: 400, body: { error: 'that is not a usable handle' } }
const handle = name ? name + userDomain : null
// ⛔ Taken by somebody ELSE is a refusal; taken by this same identity is a retry.
if (handle && peers.resolveHandle) {
const holder = peers.resolveHandle(handle)
if (holder && holder !== params.id) return { status: 409, body: { error: 'taken' } }
}
const already = peers.isFronted ? peers.isFronted(params.id) : false
try {
// ⛔ No verifyKey supplied — the lazy allow-set add. The key resolves from PLC when something
// actually asks for the repo, which is the keyless property: this bridge never holds one.
//
// ⚠ The handle goes in with it: `add()` records it, and that is what makes the well-known
// route answer for this name. Granting a handle and not recording it would be a name that
// resolves nowhere.
peers.add(params.id, handle ? { handle } : {})
} catch (err) {
return { status: 500, body: { error: (err && err.message) || String(err) } }
}
// ⛔ PERSISTED BY THE CALLER, or a restart silently unfronts everyone who asked. `frontedDids`
// is config and this is not config — it is what peers requested, and it has to outlive a boot
// the same way the handle map does.
if (onFronted) {
try {
await onFronted({ id: params.id, root: ok.root, handle })
} catch (err) {
// ⚠ It IS fronted at this point. Reporting failure would send a peer away from something
// that is working; reporting plain success would promise it survives a restart.
return { status: 202, body: { id: params.id, handle, pdsUrl, fronted: true, durable: false, reason: String(err.message || err) } }
}
}
log({ msg: 'fronting', id: params.id, handle, root: ok.root, already })
// ⛔ THE HANDLE AND THE ENDPOINT COME BACK, because the peer's next act is a PLC update putting
// exactly these two values on-chain. hpp's mint names this step: the handle and service
// endpoint "arrive LATER, as a PLC update op, when an invite from a bridge is accepted".
return { status: 200, body: { id: params.id, handle, pdsUrl, fronted: true, durable: true, already } }
}
}
}
/**
* Serve those verbs to anyone who connects on this bridge's own key.
*
* ⛔ ON THE EXISTING SWARM. This membrane already runs one to reach the peers it fronts; a second
* would be a second NAT mapping and a second set of connections for nothing.
*
* ⛔ `{ server: true, client: true }`, both load-bearing — hyper-proto-peer measured it: 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 asking a NAT'd bridge host to
* carry it is exactly that case.
*/
export function serveFronting ({ swarm, log = () => {}, ...deps }) {
if (!swarm) throw new Error('serveFronting: a swarm is required')
const handlers = frontHandlers({ ...deps, log })
const onconnection = (conn) => {
let rpc
try {
rpc = new ProtomuxRPC(conn, { protocol: PROTOCOL, valueEncoding: c.json })
} catch { return }
for (const [name, fn] of Object.entries(handlers)) {
rpc.respond(name, async (params) => {
try {
return await fn(params || {})
} catch (err) {
// ⚠ Answered, never left 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) } }
}
})
}
}
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 {} }
}
}