// 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 }