OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / collect.js

69 lines · javascript ref:
// collect.js — a registered peer asking this bridge for the observations it is HOLDING for that peer.
//
// ⛔ THE BRIDGE ACCUMULATES, THE PEER STORES. `observations.js` holds what the fediverse did to a
// registered peer's records because that peer was not there to see it, and this is how it gets them.
// We are not a writer on their feed and never will be (hyper-social #61 §4: authoring into somebody
// else's feed is exactly what removing the room primitive was meant to prevent), so the transfer has
// to be a PULL. The peer asks, the peer decides what to keep, the peer writes it.
//
// ⛔ TWO QUESTIONS, AND THE SECOND ONE IS THE ONE THAT GETS SKIPPED.
//
//   1. does this caller control the root it names?   → `admission.verifyCollect`, the same ceremony
//                                                      as claim and release, with its own prefix
//   2. does that root HOLD this name?                → here, and nowhere else
//
// Claim and release only ever needed the first: a claim names a FREE name, and a release compares the
// root against the registration as part of doing the work. A collect names a name that is already
// held by somebody, and an identity root is PUBLIC. So a caller who proves control of their own root
// while naming somebody else's username passes question 1 completely honestly — every signature
// verifies — and must still be refused. Without question 2 this verb hands a stranger the entire
// inbound social graph of anyone whose username they can read off a web page.
//
// ⛔ A READ, NOT A DRAIN. OBSERVED-INTERACTIONS.md §9 calls the buffer "bounded; prunable": bounded
// is `DEFAULT_CAP`, prunable is a separate act. Deleting rows as they are served is the obvious
// "buffer" reflex and it is wrong twice over — a peer that dies between the reply and its own write
// loses them for good, and a SECOND device holding the same name never sees them at all, which
// registrations.js explicitly supports ("a person can hold one name across a replaced device"). The
// cursor is what makes repeated collection cheap; forgetting is not required to make it correct.
//
// ⚠ `{ status, body }` like admit.js, and for the same reason: the status codes are already the
// contract a peer reads, and a second vocabulary for the swarm would be two things to keep in step.

/**
 * @param deps.registrations  who holds which name
 * @param deps.admission      the challenge/proof ceremony
 * @param deps.observations   the buffer being served
 */
export async function admitCollect ({ registrations, admission, observations }, params = {}) {
  if (!observations || !registrations) return { status: 404, body: { error: 'not found' } }
  if (!admission) return { status: 503, body: { error: 'registration is not configured on this bridge' } }

  // Question 1. Bytes cover the nonce, the name AND the cursor, so a proof lifted in flight cannot be
  // resubmitted with `since` wound back to 0 to rake in the whole buffer.
  const ok = admission.verifyCollect({
    nonce: params.nonce,
    username: params.username,
    since: params.since,
    root: params.root,
    proof: params.proof
  })
  if (!ok.ok) return { status: 403, body: { error: ok.error } }

  const held = await registrations.get(params.username)
  // ⛔ 404 RATHER THAN AN EMPTY PAGE. "nobody holds that name" and "you have no observations yet" are
  // different states and a peer acts differently on each — the first means re-claim, the second means
  // wait. An empty answer would read as the second and a device would sit there for ever.
  if (!held) return { status: 404, body: { error: 'not registered' } }

  // Question 2. `ok.root` is the root the PROOF attested, not the one the caller typed — comparing
  // `params.root` here would be comparing the request against itself.
  if (held.root !== String(ok.root).toLowerCase()) {
    return { status: 403, body: { error: 'not yours to collect' } }
  }

  const page = observations.list({ recipient: held.username, since: params.since, limit: params.limit })
  return { status: 200, body: { rows: page.rows, cursor: page.cursor, more: page.more } }
}

export default { admitCollect }