OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / feed-base.js

357 lines · javascript ref:
// Feed — one identity's log, on autobee.
//
// The write path and the read path are NOT symmetric, and pretending otherwise is the usual way to
// misuse a multi-writer log. A write is an **op appended to your own writer core**; it becomes state
// only when the linearizer reaches it and `apply` runs. A read is against the linearized view. So
// `put()` returning does not mean `get()` will see it — `update()` does.
//
// Bytes never enter the log. `put` writes the record to the store first and appends only the
// pointer, so the core carries ~50 bytes per record instead of the record.
//
// ── Why autobee and not autobase ───────────────────────────────────────────────────────────────
//
// A feed's writers are one person's devices, so the writer set is usually TWO. Under autobase's
// majority (`floor(N/2)+1`) N=2 needs both indexers to ack every node: one device goes offline and
// `indexedLength` freezes while the view keeps growing, and you cannot self-heal by dropping to one.
// For a feed that is not an edge case, it is the normal posture. autobee replaces indexers and
// quorum with provable sort weights, which retires that class outright.
//
// ── The three autobee traps, all of which bite here ────────────────────────────────────────────
//
//  1. `node.key` is the writer key. autobase puts it on `node.from`. Attribution must never come
//     from the op body, so getting this wrong is silent misattribution rather than a crash.
//  2. The `view` inside apply is a WRITABLE hyperbee2 batch; `db.view` is a read-only snapshot.
//  3. hyperbee2 has no put/del and no key/valueEncoding — Buffers, and every mutation is a batch.

import Autobee from 'autobee'
import b4a from 'b4a'
import { applyOp, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING, LISTING_ROOT } from './feed.js'
import { RecordLog } from './log.js'

const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
const enc = (v) => b4a.from(JSON.stringify(v), 'utf8')
const dec = (b) => (b == null ? null : JSON.parse(b4a.toString(b, 'utf8')))

// The { get, set } surface applyOp expects: reads from the bee, writes into one batch.
//
// A WriteBatch has tryPut/tryDelete/flush and NO get — measured, not assumed. So reads go to the
// bee, and a pending overlay carries writes made EARLIER IN THE SAME BATCH: `applyAddDevice` writes
// the roster entry and a later op in the same batch checks it via `isAdmitted`, which would
// otherwise miss. The overlay also lets the whole apply call flush ONCE rather than once per op.
function batchView (bee, batch) {
  const pending = new Map()
  return {
    pending,
    async get (key) {
      if (pending.has(key)) return pending.get(key)
      const node = await bee.get(b4a.from(key, 'utf8'))
      return node ? dec(node.value) : null
    },
    async set (key, value) {
      pending.set(key, value)
      batch.tryPut(b4a.from(key, 'utf8'), enc(value))
    }
  }
}

async function apply (nodes, view, host) {
  const batch = view.write()
  const v = batchView(view, batch)

  for (const node of nodes) {
    let op
    try { op = dec(node.value) } catch { continue } // a malformed block is a no-op, deterministically
    // ⛔ node.key, not node.from.key.
    const device = node.key ? hex(node.key) : null

    if (op && op.t === 'add-device' && typeof op.device === 'string') {
      const before = await v.get(ROSTER + '/' + op.device)
      await applyOp(v, op, { device })
      const after = await v.get(ROSTER + '/' + op.device)
      if (!before && after) {
        // ⛔ Ack ONLY here — after applyOp accepted the proof. `add-device` arrives optimistically
        // because an unadmitted device is not yet a writer and has nowhere else to put a block, and
        // an optimistic block that is acked STICKS IN THE LOG. So the ack has to be downstream of
        // the authorisation, not beside it: acking first would let anyone with a socket append.
        try { host.ackWriter(node.key) } catch {}
        // Writer admission is a HOST call and must happen with the roster entry, or an admitted
        // device would have no core to write into. Only apply may do either.
        try { host.addWriter(b4a.from(op.device, 'hex'), { isIndexer: true }) } catch {}
      }
      continue
    }

    // ⛔ Optimistic blocks must self-verify before being acked, or "any writer could append blocks
    // even when not added to the system" (autobee README). The verification here is deliberately
    // narrow: a KNOCK is acked, and nothing else ever is. An unadmitted device's put or add-device
    // is already refused by applyOp, but refusing it is not enough — acking it would let the block
    // itself stick in the log, which is the exploit the README warns about.
    if (op && op.t === 'knock') {
      try { host.ackWriter(node.key) } catch {}
    }

    await applyOp(v, op, { device })
  }

  await batch.flush()
}

export class Feed {
  constructor (store, key, opts = {}) {
    this.corestore = store
    // fast-forward: OFF unless a caller hands us a boot head — a configuration choice, not a
    // workaround for a defect.
    //
    // Fast-forward is on by default and PROBES for candidate oplog heads. Our joins are degenerate
    // for that: one serving peer, no stamped heads anywhere, so there are zero candidates and each
    // wakeup batch pays DEFAULT_OP_TIMEOUT (5s, lib/constants.js:5) looking for something that does
    // not exist. Measured at N=25 writers: 25s vs 0.2s. Turning it off is not fixing autobee, it is
    // declining to search when we know there is nothing to find.
    //
    // With a boot head it works and is fast — 201ms, and it does fast-forward.
    //
    // ⚠ A boot head is an OPLOG head — `{key, length}` of a writer's core — and a joiner cannot
    // derive one from a feed key. It has to be CONVEYED or REMEMBERED: from a registry listing, a
    // follow record, a knock, or our own record of a previous session. The seam is here for when we
    // carry one.
    //
    // ★ If we ever want DISCOVERY-based fast-forward instead, the handler that matters is
    // `mostRecentTrusted`, and it belongs on the peer that SERVES, not the one joining:
    // `lib/writers.js:112` — flushLocal passes its result into `w.flush(views, trusted)`, stamping
    // the vouched head into that peer's own oplog for joiners to read. Note also `trusted.js:19`:
    // supplying `mostRecentTrusted` flips the `isTrusted` default from permissive to deny, because
    // opting into the trust system means opting out of trusting everything. Both are coherent; they
    // are just not obvious from the outside.
    const bootHead = opts.bootHead || null
    this.base = new Autobee(store, key || null, {
      apply,
      optimistic: !!opts.optimistic, // the stranger doorbell, off by default
      fastForward: bootHead ? { boot: { head: bootHead } } : false,
      ...(opts.encryptionKey ? { encrypted: true, encryptionKey: opts.encryptionKey } : {})
    })
    this.store = opts.store || null // the RecordStore holding the bytes
    this._log = null
  }

  async ready () {
    await this.base.ready()
    // RecordLog over a READ-ONLY adapter: this log resolves and never writes, because a write here
    // has to be an append. Its three-state resolve() is the whole reason to reuse it.
    this._log = new RecordLog(this._readKv(), this.store)
    return this
  }

  get key () { return this.base.key }
  get writable () { return this.base.writable }
  get deviceKey () { return hex(this.base.local.key) }

  _readKv () {
    const bee = () => this.base.view
    return {
      set: async () => { throw new Error('Feed: the view is read-only — writes are appends') },
      del: async () => { throw new Error('Feed: the view is read-only — writes are appends') },
      get: async (key) => {
        const node = await bee().get(b4a.from(key, 'utf8'))
        return node ? dec(node.value) : null
      },
      list: async (prefix = '') => {
        const out = {}
        const range = prefix
          ? { gte: b4a.from(prefix, 'utf8'), lt: b4a.from(prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1), 'utf8') }
          : {}
        for await (const node of bee().createReadStream(range)) {
          out[b4a.toString(node.key, 'utf8')] = dec(node.value)
        }
        return out
      }
    }
  }

  // ── write side: appends. None of these are visible until update() ──────────────────────────────

  // Admit a device. The founding call is a device admitting ITSELF, which only takes effect while
  // the view has no founder — see feed.js. Everything after needs an already-admitted device.
  //
  // ⚠ autobee also exposes `host.genesis` for exactly this, which would let feed.js drop its FOUNDER
  // marker. Deliberately NOT taken here: this port must not change behaviour, and FOUNDER is what
  // the substrate-agnostic tests prove. One change at a time.
  /**
   * Admit this device by proving it belongs to the feed's owner.
   *
   * The proof must attest THIS feed's writer key — `deviceKey`, i.e. `base.local.key`.
   *
   * ⛔ That key is not something a caller chooses. Passing `keyPair` to the log sets its BOOT
   * MANIFEST, not the local writer core — measured: with a keyPair supplied, `base.local.key` was
   * still a corestore-derived key and did not match. So the credential cannot be minted ahead of
   * time; the feed is opened FIRST, its writer key read, and the proof minted for that key. Which
   * is also the honest claim to be making: "this writer core belongs to me".
   *
   * Self-admission: no other device has to be online or to vouch. Genesis is the same call — the
   * first one binds the proof's identity root into the feed permanently.
   *
   * ⛔ Appended OPTIMISTICALLY. A device not yet on the roster is not yet a writer and has nowhere
   * else to put a block; apply acks it only once the proof verifies.
   */
  async addDevice (proof) {
    if (!proof) {
      throw new Error('Feed.addDevice: a chain proof for ' + this.deviceKey + ' is required — ' +
        'admission is gated by attestation, and there is no unattested feed')
    }
    await this.base.append(enc({ t: 'add-device', device: this.deviceKey, proof }),
      { optimistic: true })
  }

  /**
   * Revoke a device. A tombstone, and it reaches everything that device attested.
   *
   * ⚠ You may revoke yourself, including the last device, which freezes the feed. Recoverable: a
   * device minted fresh from the vault descends from nothing revoked and admits itself normally.
   */
  async removeDevice (device) {
    await this.base.append(enc({ t: 'remove-device', device: hex(device) }))
  }

  // Bytes to the store, pointer to the log. If the append never lands the bytes are merely
  // unannounced; reversed, the pointer would name bytes nobody has.
  async put (collection, name, record) {
    const { oid, size } = await this.store.put(collection, name, record)
    await this.base.append(enc({ t: 'put', collection, name, $oid: oid, size }))
    return { oid, size }
  }

  // Ring someone's doorbell: append to THEIR feed, optimistically, naming MY feed. It grants
  // nothing and asks for nothing except to be seen.
  //
  // ⚠ Requires holding their base, which requires a transport. PROVEN END TO END 2026-09-15 in
  // doorbell-wire.test.js: a reader who is not a writer appends across a replication pipe and the
  // row lands in the owner's feed, with her writer set untouched and a record from him refused.
  //
  // ⛔ `base.writable` GOES TRUE ON THE APPENDER after an optimistic append — it means "I have a
  // local writer core", never "my appends are admitted". Do not use it as an authorisation check.
  //
  // ⛔ THE KNOCK PROVES WHO IS KNOCKING (feed.js applyKnock). `bind(writerKeyHex)` returns a proof
  // binding OUR writer core on their log to our identity root — `bindWriter(cred, key)`, the same
  // proof add-device carries. It is a function rather than a credential so that this file never
  // imports the vault code (device.js): only the node holding the credential builds one, and a
  // read-only holder that never knocks never loads it.
  async knock (myFeedKey, bind) {
    if (typeof bind !== 'function') {
      throw new Error('Feed.knock: a binder is required — a knock must prove which identity is knocking ' +
        '(run `favela link` so this device has a credential)')
    }
    await this.base.append(enc({ t: 'knock', feed: hex(myFeedKey), proof: bind(this.deviceKey) }), { optimistic: true })
  }

  // Honour a knock by listing that feed. Bytes first, then the op — same order as `put`, and for the
  // same reason: a pointer naming bytes nobody has is worse than bytes nobody has announced.
  //
  // ⚠ apply refuses this unless an active knock exists for the feed, so listing something that never
  // asked is not merely discouraged, it does not land. Order matters: list, THEN dismiss.
  //
  // `root` is the identity whose proven knock is being honoured; apply refuses the listing unless that
  // knock is live and named THIS feed.
  async listFeed (root, feedKey, record) {
    const feed = hex(feedKey)
    const { oid, size } = await this.store.put(LISTING, feed, record)
    await this.base.append(enc({ t: 'list-feed', root: hex(root), feed, $oid: oid, size }))
    return { oid, size }
  }

  async delist (feedKey) {
    await this.base.append(enc({ t: 'delist', feed: hex(feedKey) }))
    try { await this.store.del(LISTING, hex(feedKey)) } catch {}
  }

  // Each listing with the identity root it was granted to. A reader must still check that the feed's
  // founder IS that root before trusting it (Peer.directory): apply cannot read the listed feed.
  async listings () {
    const rows = await this.list(LISTING)
    const out = []
    for (const r of rows) {
      const granted = await this._readKv().get(LISTING_ROOT + '/' + r.name)
      out.push({ feed: r.name, record: r.record, root: granted && granted.$deleted !== true ? granted.root : null })
    }
    return out
  }

  async dismissKnock (root) {
    await this.base.append(enc({ t: 'dismiss-knock', root: hex(root) }))
  }

  // Live knocks: the PROVEN identity root that knocked, the feed it asked about, and its writer core.
  //
  // ⛔ A row without `feed` was written by 0.0.57's apply (`__knocks__/<feed key>`, no proof). An
  // upgrade does not reindex the view, so such rows survive; the proven-knock apply never writes one,
  // so a fresh node computing the same log has no knock there. Dropping them here makes an upgraded
  // node agree with a fresh one (test/knock-legacy.test.js).
  async knocks () {
    const map = await this._readKv().list(KNOCKS + '/')
    return Object.entries(map)
      .filter(([, v]) => v && v.$deleted !== true && typeof v.feed === 'string')
      .map(([k, v]) => ({ root: k.slice(KNOCKS.length + 1), feed: v.feed, device: v.device }))
      .sort((a, b) => (a.root < b.root ? -1 : a.root > b.root ? 1 : 0))
  }

  async del (collection, name) {
    await this.base.append(enc({ t: 'del', collection, name }))
    try { await this.store.del(collection, name) } catch {}
  }

  // ── read side: the linearized view ────────────────────────────────────────────────────────────

  async update () { await this.base.update() }
  async resolve (collection, name) { return this._log.resolve(collection, name) }
  async get (collection, name) { return this._log.get(collection, name) }
  async list (collection = null) { return this._log.list(collection) }
  // Live pointers, WITHOUT fetching any bytes — see RecordLog.pointers. This is what lets the
  // mirror decide which records to go and get instead of getting all of them at once.
  async pointers (collection = null) { return this._log.pointers(collection) }
  async unresolved (collection = null) { return this._log.unresolved(collection) }
  // Positively deleted names — see RecordLog.tombstones. What a bridge holding this feed retracts on.
  async tombstones (collection) { return this._log.tombstones(collection) }

  // Admitted WRITER cores, excluding any whose device has been revoked.
  async devices () {
    return (await this.roster()).map((r) => r.writer).sort()
  }

  /**
   * The roster as { writer, device } pairs, revoked devices excluded.
   *
   * ⛔ Both keys, because they are different things and only one of them can be revoked. A listing
   * that showed only writer cores would name something `unlink` cannot take — you would be able to
   * see a device and not retire it.
   */
  async roster () {
    const rows = await this._readKv().list(ROSTER + '/')
    const dead = await this._readKv().list(REVOKED + '/')
    const out = []
    for (const [k, v] of Object.entries(rows)) {
      if (!v || !v.device) continue
      if (dead[REVOKED + '/' + v.device] != null) continue
      out.push({ writer: k.slice(ROSTER.length + 1), device: v.device })
    }
    return out.sort((a, b) => (a.writer < b.writer ? -1 : a.writer > b.writer ? 1 : 0))
  }

  /** Revoked device keys — named, because a retired device is a fact, not an absence. */
  async revokedDevices () {
    const dead = await this._readKv().list(REVOKED + '/')
    return Object.keys(dead).map((k) => k.slice(REVOKED.length + 1)).sort()
  }

  async founder () { return this._readKv().get(FOUNDER) }

  // ── replication ───────────────────────────────────────────────────────────────────────────────
  //
  // Replicate through the BASE. autobee owns which cores a peer needs — the writer set changes as
  // devices are admitted, so the base is the only thing that knows the full set at any moment.
  // `isInitiatorOrStream` is a boolean (make a stream) or an existing duplex, so a transport can
  // hand us a socket without this file knowing what a socket is.
  replicate (isInitiatorOrStream) { return this.base.replicate(isInitiatorOrStream) }

  get discoveryKey () { return this.base.discoveryKey }

  async close () { await this.base.close() }
}