OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / admit.js

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