// admit.js — claiming and releasing a name, once, for every transport that offers it.
//
// ⛔ ONE IMPLEMENTATION, TWO ADAPTERS. This lived inside the HTTP route, which was fine while HTTP
// was the only way in. It is not: favela-peer has NO outbound HTTP at all (measured — no bare-fetch,
// no bare-https, nothing), so a peer asking a bridge for a name has to do it over the swarm, which
// both sides already speak. Copying the flow into a second transport would be the "written three
// times and the third one written wrong" failure `favela-markdown/lib/reach.js` names.
//
// So the rules live here — proof first, then policy, then hold the feed — and `router.js` and
// `register-rpc.js` are two doors onto the same room.
//
// ⚠ Returns `{ status, body }` rather than throwing. The status codes are HTTP's because they are
// already the contract a peer reads (`taken` → pick another, `invite required` → go ask the
// operator, 403 → you did not prove control) and inventing a second vocabulary for the swarm would
// mean two things to keep in step.
// The name a person actually types, or null when this bridge has no host configured. One helper
// because BOTH success paths must answer it the same way — they did not, and the difference was the
// bug.
function acctFor (config, username) {
const host = config && config.host
return host ? username + '@' + host : null
}
export async function admitClaim ({ registrations, admission, config, onRegistered, observerFor = null, bridgeKeyPair = null }, params = {}) {
if (!registrations) return { status: 404, body: { error: 'not found' } }
if (!admission) return { status: 503, body: { error: 'registration is not configured on this bridge' } }
// ⛔ CONTROL, BEFORE CONSENT — and before the request's shape is even examined. hyper-social #65
// §8: admission needs both and neither substitutes for the other. An identity root is PUBLIC, so
// presenting one proves nothing; this is the half that was only ever asserted in a comment.
const ok = admission.verifyClaim({
nonce: params.nonce,
username: params.username,
feedKey: params.feedKey || null,
root: params.root,
proof: params.proof
})
if (!ok.ok) return { status: 403, body: { error: ok.error } }
const r = await registrations.claim({
username: params.username,
root: params.root,
feedKey: params.feedKey || null,
invite: params.invite || null
})
if (!r.ok) {
// ⛔ Distinguished, because a peer acts differently on each: `taken` means pick another name,
// `invite required` means go and ask the operator, and a bad request is the peer's own bug.
const status = r.error === 'taken' ? 409 : r.error === 'invite required' ? 403 : 400
return { status, body: { error: r.error } }
}
// ⛔ THE USER'S OBSERVATION FEED, VOUCHED FOR BY THIS BRIDGE (hyper-social #82, I1 S3). What the fediverse does
// to this user is written into a feed the bridge founds for them; the peer holds it and pins its founder. The
// grant is signed with the bridge's swarm key — the key the peer claimed against (operator decision O-2 b) — and
// travels with EVERY successful claim, re-claims included, which is how an already-registered peer learns it.
// Optional: a bridge with no observer, or one whose feed could not open, answers exactly as before.
const observerGrant = async () => {
if (!observerFor || !bridgeKeyPair) return null
try {
const g = await observerFor({ username: r.username, generation: r.generation })
if (!g || !g.observationFeed || !g.observerRoot) return null
const { signObserver } = await import('favela-identity/lib/observer-grant.js')
const grant = { username: r.username, generation: r.generation, observationFeed: g.observationFeed, observerRoot: g.observerRoot }
return { observationFeed: g.observationFeed, observerRoot: g.observerRoot, sig: signObserver(grant, bridgeKeyPair) }
} catch {
return null
}
}
// ⛔ HOLD THE FEED NOW, so a registration is a served actor rather than a row in a file. The caller
// supplies this: neither this file nor the router knows about swarms or corestores.
if (onRegistered) {
try {
await onRegistered({ username: r.username, root: params.root, feedKey: params.feedKey || null })
} catch (err) {
// ⚠ The name IS claimed at this point. Reporting success would promise an actor that does not
// serve; releasing it would lose a name the peer was just told it had. So: claimed, and honest
// that it is not live yet.
// ⛔ THE ACCT TRAVELS WITH A PENDING CLAIM TOO. A peer records the name off `acct` and nothing
// else; omitting it here meant the ONE path where the claim succeeded but the actor was not yet
// live recorded nothing on the device, while the bridge went on holding the name. The device
// then showed "none yet" for ever and could neither re-claim nor release. `pending` already
// says it is not servable — that is the honest part; withholding the name is not.
const observer = await observerGrant()
return { status: 202, body: { username: r.username, generation: r.generation, acct: acctFor(config, r.username), pending: String(err.message || err), ...(observer ? { observer } : {}) } }
}
}
const host = config && config.host
const observer = await observerGrant()
return {
status: 200,
body: {
username: r.username,
generation: r.generation,
acct: acctFor(config, r.username),
actor: `https://${host}/ap/users/${r.username}/actor`,
inbox: `https://${host}/ap/users/${r.username}/inbox`,
...(observer ? { observer } : {})
}
}
}
export async function admitRelease ({ registrations, admission, onReleased }, params = {}) {
if (!registrations) return { status: 404, body: { error: 'not found' } }
if (!admission) return { status: 503, body: { error: 'registration is not configured on this bridge' } }
// ⛔ PROVED, because releasing BURNS the identity. The generation counter deliberately makes a
// reused name a different actor, so there is no way back — and this used to compare a bare `root`,
// which is public. Anyone who could read a root could destroy the actor behind it.
const ok = admission.verifyRelease({ nonce: params.nonce, username: params.username, root: params.root, proof: params.proof })
if (!ok.ok) return { status: 403, body: { error: ok.error } }
const rel = await registrations.release(params.username, { root: params.root })
if (!rel.ok) return { status: rel.error === 'not registered' ? 404 : 403, body: { error: rel.error } }
if (onReleased) { try { await onReleased(params.username) } catch {} }
return { status: 200, body: { released: rel.username } }
}
export default { admitClaim, admitRelease }