OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / feed.js

307 lines · javascript ref:
// The feed's apply — the deterministic core of one identity's log.
//
// A feed is ONE identity. Devices are plural; the member is singular and is the feed itself, so the
// authorisation rule fits in a sentence: an op counts if the device that wrote it is in this feed's
// device roster. Nothing else grows the writer set. That is not a detail — it is the property that
// stops a popular feed from turning every person who interacts with it into a hypercore, and it is
// why interactions are written into the interactor's OWN feed instead of the author's.
//
// ── The two rules apply must obey ──────────────────────────────────────────────────────────────
//
// DETERMINISTIC. This runs inside autobase and is replayed on every boot and every reorg. No clock,
// no randomness, no external lookup, no I/O. Every peer must reach the same view from the same ops.
//
// TOTAL. It must never throw. A throw mid-replay wedges the base, and the op that caused it is
// already durable — so there is no version of "fix it and retry". A rejected op is a NO-OP, applied
// identically by everyone, and a malformed one is rejected the same way as an unauthorised one.
//
// Note what follows from ordering alone: a write from a device that has not been admitted YET is
// dropped, and admitting it later does not retroactively count that write. The linearization is the
// whole authority; there is no second pass.

import { readProof } from './attestation.js'

const SEP = '/'

// The replicated device roster. Reserved namespace: it lives in the same view as the records, so it
// converges with them, but no `put` can reach it — otherwise a rostered device could forge an
// admission by writing a record, which is admission without the admission op.
export const ROSTER = '__devices__'

// The doorbell. A stranger holds nothing of mine and I hold nothing of theirs, so neither
// replication nor a follow can carry a first contact; a knock is how one arrives.
//
// ⛔ A knock GRANTS NOTHING. It records that a feed key would like to be seen, and that is all. It
// is the one op accepted from an unadmitted device, it can only write its own entry here, and
// accepting it means *I* follow *them* — never that they gain write access to my feed. Write access
// to a feed belongs to its owner's devices and to nobody else; a doorbell is exactly where that
// would be easiest to lose.
//
// Reserved, so it converges with the records but can never be materialised as one.
export const KNOCKS = '__knocks__'

// A registry is a feed whose records are LISTINGS of other feeds. No new primitive — this is the
// collection those records live in, named so the rule below has something to attach to.
export const LISTING = 'listing'

// The identity root a listing was granted to, beside its pointer.
//
// ⛔ Not ON the pointer: a log value is EXACTLY { $oid, size } (RecordLog `isPointer`), and anything
// else makes the record unresolvable on every node. Reserved, so it converges with the listing but can
// never be materialised as a record. A reader checks the listed feed's founder against it
// (Peer.directory), which is the half of consent apply cannot see: this log cannot read that feed.
export const LISTING_ROOT = '__listing_root__'

// Genesis marker. Set once, by the founding admission, and never read for anything else.
//
// ⛔ autobee's `host.genesis` looks like it should replace this, and its README says to use it to
// bootstrap the first writer. It cannot be used as a per-node admission gate: `isGenesis()` reads
// the system bee's HEAD, which does not move until the batch flushes, so genesis stays true for
// every node of the batch that admitted the first writer. With `optimistic: true` two strangers'
// self-admissions landing in one batch would BOTH be admitted. Proven in test/genesis.test.js,
// which is kept as a regression test. This marker is visible to the very next op in the same batch
// because it goes through the apply-time pending overlay, which is exactly the difference.
//
// It replaces asking "is the roster empty?", which meant enumerating the view. That was fine
// against a Map and wrong against the real thing: the view is a Hyperbee, the scan is O(n) on every
// admission, and it is async — so the question could not be answered inside apply without either
// lying or scanning. A single key answers it in one get, and makes genesis explicit in the view
// rather than inferred from the absence of something.
export const FOUNDER = '__founder__'

// Revoked DEVICE keys.
//
// ⛔ Revocation targets the device, not the writer core. A chain is made of device keys — the only
// keys that can sign — so ancestry is only expressible over them, and a tombstone on a writer key
// could never reach what that device attested. One device may also hold several writer cores (one
// per feed it participates in); revoking the device should retire all of them at once.
//
// Reserved, so it converges with the records and can never be materialised as one.
export const REVOKED = '__revoked__'

export const isRosterKey = (key) => typeof key === 'string' && key.startsWith(ROSTER + SEP)

// A path segment: non-empty, no separator, not a traversal.
const isSeg = (s) => typeof s === 'string' && s.length > 0 && !s.includes(SEP) && s !== '.' && s !== '..'
// Reserved collections are control data and are never writable by an op.
const isReserved = (collection) => typeof collection === 'string' && collection.startsWith('__')
// A pointer is exactly { $oid, size } — see RecordLog. An op carries the pointer's fields inline,
// so this checks the fields rather than the object shape.
const isPointerOp = (op) => '$oid' in op && typeof op.size === 'number'

const rosterKey = (writer) => ROSTER + SEP + writer
const revokedKey = (device) => REVOKED + SEP + device

// ⛔ A revoked device is NOT admitted. Absence and revocation are different facts — absence may
// simply mean "not replicated to me yet" — so revocation is a tombstone and every admission check
// has to read through it rather than around it.
// A writer counts if it is on the roster AND the device it was admitted under is not revoked.
// Both halves matter: the roster says "this core belongs here", the revocation list says "that
// device no longer does", and a writer whose device was retired must stop counting immediately
// without anyone having to walk the roster to tombstone it.
const isAdmitted = async (view, writer) => {
  if (!isSeg(writer)) return false
  const rec = await view.get(rosterKey(writer))
  if (rec == null) return false
  return !(await isRevokedDevice(view, rec.device))
}

const isRevokedDevice = async (view, device) => {
  if (!isSeg(device)) return false
  return (await view.get(revokedKey(device))) != null
}

/**
 * applyOp(view, op, { device })
 *
 * `view` is the KV being built — the linearized state. `device` is the writer key autobase
 * attributes the op to; this function trusts that attribution and nothing else about the op.
 *
 * Returns nothing. A rejected op leaves the view untouched.
 *
 * Async because the real view is a Hyperbee. Every read is awaited; nothing else about the contract
 * changes, and determinism is unaffected — the awaits are I/O against state this same replay built.
 */
export async function applyOp (view, op, ctx) {
  if (!op || typeof op !== 'object') return
  const device = ctx && ctx.device
  if (!isSeg(device)) return

  switch (op.t) {
    case 'add-device': return applyAddDevice(view, op, device)
    case 'remove-device': return applyRemoveDevice(view, op, device)
    case 'put': return applyPut(view, op, device)
    case 'del': return applyDel(view, op, device)
    case 'knock': return applyKnock(view, op, device)
    case 'dismiss-knock': return applyDismissKnock(view, op, device)
    case 'list-feed': return applyListFeed(view, op, device)
    case 'delist': return applyDelist(view, op, device)
    default: // an unknown op is retained by the log and ignored by this version. A future op type
      // must be additive for exactly this reason: old peers consume and discard it, and upgrading
      // does not re-run history.
  }
}

// Admission — a device proves it belongs to this feed's owner, and admits ITSELF.
//
// ── The rule ──────────────────────────────────────────────────────────────────────────────────
//
// Genesis binds an identity ROOT into `__founder__`. After that, the only way onto the roster is a
// chain proof resolving to that root. There is no vouching, so no admitted device need be online,
// and the "two devices each believe they are first" ambiguity is gone: two devices under one root
// are both legitimately admitted, in whatever order linearizes.
//
// ⛔ There is no unattested feed. Genesis without a proof is refused, so there is no second
// authorisation rule to remember and no mode anyone is in by accident.
//
// ── Why this is allowed inside apply ──────────────────────────────────────────────────────────
//
// `readProof` is a pure function of the proof's bytes — no clock, no I/O, no lookup — and it never
// throws (`IdentityKey.verify` does; the wrapper is why). Determinism and totality both hold.
//
// ── The order of the checks is load-bearing ───────────────────────────────────────────────────
//
// Revocation is tested BEFORE the proof, because a removed device still holds a perfectly valid
// proof. If the proof were enough, self-service admission would make revocation decorative.
async function applyAddDevice (view, op, device) {
  const writer = op.device
  if (!isSeg(writer)) return

  // ⛔ The proof binds THIS writer key as attested data, so a proof is never a bearer token for
  // some other core. Verified in the same call that walks the chain.
  const proof = readProof(op.proof, writer)
  if (!proof) return

  // ⛔ Revocation is checked AFTER the proof only because the proof is what tells us WHICH device
  // is being claimed — but before any admission is written. A retired device still holds a
  // perfectly valid proof, so without this it would simply re-admit itself and revocation would be
  // decorative.
  if (await isRevokedDevice(view, proof.device)) return

  // ⛔ Transitive. A linked device can attest the next one from its own keypair — no mnemonic —
  // which is how a second machine is paired. So a compromised device has descendants, and every
  // one of them carries a chain that names it.
  for (const ancestor of proof.chain) {
    if (await isRevokedDevice(view, ancestor)) return
  }

  const founder = await view.get(FOUNDER)

  if (!founder) {
    // Founding is self-admission: the core writing the op is the core being admitted. The root its
    // device chains to becomes the feed's owner, once, permanently.
    if (writer !== device) return
    await view.set(FOUNDER, { device: writer, identityRoot: proof.root })
    await view.set(rosterKey(writer), { device: proof.device, admittedBy: device })
    return
  }

  // Someone else's identity, however valid their proof.
  if (proof.root !== founder.identityRoot) return

  await view.set(rosterKey(writer), { device: proof.device, admittedBy: device })
}

// Revocation. The owner's act, so it needs an admitted device — and it is a TOMBSTONE, because
// absence is ambiguous in a replicated log and "removed" has to be positive evidence.
//
// ⚠ A device may revoke itself, and the last device may revoke itself, freezing the feed. That is
// deliberate and it is recoverable: a device minted fresh from the vault descends from nothing
// revoked, so it admits itself normally. This is exactly why the vault is needed for RECOVERY and
// never at boot.
async function applyRemoveDevice (view, op, device) {
  if (!(await isAdmitted(view, device))) return
  if (!isSeg(op.device)) return
  await view.set(revokedKey(op.device), { by: device })
}

async function applyPut (view, op, device) {
  if (!(await isAdmitted(view, device))) return
  if (!isSeg(op.collection) || !isSeg(op.name)) return
  if (isReserved(op.collection)) return
  if (!isPointerOp(op)) return
  await view.set(op.collection + SEP + op.name, { $oid: op.$oid, size: op.size })
}

// A delete is a TOMBSTONE. Absence is ambiguous in a replicated log — "deleted" and "not replicated
// to me yet" look identical — so deletion has to be positive evidence.
async function applyDel (view, op, device) {
  if (!(await isAdmitted(view, device))) return
  if (!isSeg(op.collection) || !isSeg(op.name)) return
  if (isReserved(op.collection)) return
  await view.set(op.collection + SEP + op.name, { $deleted: true })
}

// A knock: an IDENTITY asks to be seen, and names the feed it is asking about.
//
// ⛔ THE KNOCK PROVES WHO IS KNOCKING (hyper-social #77, 2026-10-12). It used to record
// `__knocks__/<op.feed>` for whatever feed the op named, and a registry then read that row as the
// named feed's consent to be listed — so anyone could knock "as" any feed and have it listed without
// its owner ever asking. This log cannot read another feed's log, so "this device belongs to feed X"
// is unprovable here. "This device chains to identity root R" is provable, from the proof's own bytes,
// exactly as add-device does it: `readProof` binds THIS writer core and returns the root, or null.
// So the row is keyed by the proven ROOT, it carries the feed the root named, and a READER checks
// that feed's founder against the root before trusting a listing (Peer.directory).
//
// Keyed by the root, so a flood from one identity is one row rather than many: the bound is
// structural instead of a rate limit, which would have to live inside apply and would make the view
// non-deterministic.
//
// No admission check, deliberately: a stranger is the entire point. What bounds it is that this is
// the ONLY thing a stranger can do, and it writes one entry in a namespace that can never become a
// record.
//
// ⚠ An unproven knock is now a no-op, including ones already in a log: views are re-derived, so they
// drop on the next reindex. Measured before shipping: zero listings on any node, one knock (between two
// of our own feeds).
async function applyKnock (view, op, device) {
  if (!isSeg(op.feed)) return
  const proof = readProof(op.proof, device)
  if (!proof) return
  await view.set(KNOCKS + SEP + proof.root, { device, feed: op.feed })
}

// Clearing a knock is the owner's act, so unlike the knock itself it requires admission. Answered
// or ignored, the entry goes; the knocker learns nothing either way, which is the point — a doorbell
// that reported "dismissed" back would be a channel a stranger could probe.
async function applyDismissKnock (view, op, device) {
  if (!(await isAdmitted(view, device))) return
  if (!isSeg(op.root)) return
  await view.set(KNOCKS + SEP + op.root, { $deleted: true })
}

// ★ Listing a feed requires TWO acts by TWO parties: a live, PROVEN knock from an identity naming
// this feed, and an admitted device of this registry choosing to honour it.
//
// That makes consent structural: a registry cannot list a feed on a knock nobody proved, nor on a knock
// that named a different feed. hyper-social #61 §8 requires the two acts, subscribing and being listed,
// to stay separate. The root goes beside the pointer (LISTING_ROOT) so a reader can close the last gap
// apply cannot: that the listed feed really is that identity's.
//
// ⚠ What it does NOT give: a delisted feed is not retracted (the listing already replicated), and
// an unlisted feed is not private (a feed announces on a topic derived from its key). A registry is
// a phone book, not a gate.
async function applyListFeed (view, op, device) {
  if (!(await isAdmitted(view, device))) return
  if (!isSeg(op.feed) || !isSeg(op.root)) return
  if (!isPointerOp(op)) return
  const knocked = await view.get(KNOCKS + SEP + op.root)
  // A dismissed knock is a tombstone, not an absence — consent withdrawn before use is consent
  // refused, and it must not read as consent given.
  if (!knocked || knocked.$deleted === true) return
  if (knocked.feed !== op.feed) return // that identity asked about a different feed
  await view.set(LISTING + SEP + op.feed, { $oid: op.$oid, size: op.size })
  await view.set(LISTING_ROOT + SEP + op.feed, { root: op.root })
}

// Delisting is the registry's act, so it needs admission. A tombstone, like every other deletion
// here: absence is ambiguous in a replicated log and "delisted" has to be positive evidence.
async function applyDelist (view, op, device) {
  if (!(await isAdmitted(view, device))) return
  if (!isSeg(op.feed)) return
  await view.set(LISTING + SEP + op.feed, { $deleted: true })
  await view.set(LISTING_ROOT + SEP + op.feed, { $deleted: true })
}

export default { applyOp, isRosterKey, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING, LISTING_ROOT }