OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / node.js

837 lines · javascript ref:
// FavelaNode — one process, many feeds, ONE of everything underneath.
//
// The shape is not a convenience. Three separate lessons land on it:
//
//   ⛔ Two Autobees on one Corestore need SEPARATE NAMESPACES or the second derives the same local
//      writer as the first and the pair DEADLOCKS — it does not error, it hangs (measured
//      2026-09-06, replicate.test.js).
//   ★  keet carries many rooms on ONE corestore and ONE swarm, per-room autobase keyed by discovery
//      key. That topology is proven, not hypothetical.
//   ⚠  hyper-proto-peer learned the same thing the expensive way: one shared store + one swarm +
//      one overlay per DID, namespaced.
//
// So: one storage dir, one Corestore, one HyperOverlay, one swarm — and every feed is a namespace.

import Corestore from 'corestore'
import { HyperOverlay } from 'hyper-overlay'
import b4a from 'b4a'
import fs from '#node:fs'
import { join } from '#node:path'
import { Feed } from './feed-base.js'
import { RecordStore } from './record-store.js'
import { Peer } from './peer.js'
import { loadCredential, saveCredential, bindWriter, loadFeedKey, saveFeedKey } from './device.js'
import { Browsers } from './browsers.js'
import { Pairing } from './pairing.js'
import { Avatars } from './avatars.js'
import { Files } from './files.js'
import { Namespace } from './namespace.js'
import { loadNamespace, saveNamespace, namespaceSlot, slotFor, loadNamespaces, saveNamespaces, loadRooms, saveRooms } from './device.js'
import { Room } from './room.js'
import { readAttested } from './attestation.js'
import { createMirror } from './mirror.js'
import { connectBridge, buildClaim, observers, acceptObserver, claimTargets } from './bridge.js'

const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))

/**
 * May this node adopt `incomingFeed` as its own?
 *
 * ⛔ Pairing happens AT INIT — a phone joining an identity it has never written under — so adopting
 * is the normal path and this guard is for the case that is not init.
 *
 * ⚠ It counts RECORDS, not founders. An unlinked node has an empty, unfounded feed (opening a log
 * is not founding it), so a "no founder" test would refuse every legitimate pairing; counting
 * records refuses exactly the case that would lose something.
 *
 * Pure, so it can be tested without a swarm.
 */
export function adoptionRefusal (recordCount, incomingFeed, ownFeed) {
  if (!incomingFeed || incomingFeed === ownFeed) return null // nothing to adopt
  if (recordCount > 0) {
    return 'this node has already written ' + recordCount + ' record(s) to its own feed, and ' +
      'joining another would abandon them. Pair a fresh node, or move those records first.'
  }
  return null
}

/**
 * Does the feed a claim points at AGREE that the claim's identity owns it?
 *
 * Pure, so it can be tested without a node, a store or a swarm — the same reason `adoptionRefusal`
 * above is pure. Everything that decides anything lives here; `resolveHandle` is only the lookup
 * that finds the two inputs.
 *
 *   confirmed    the feed's founder IS the claim's anchor
 *   MISMATCH     the feed is founded by someone else — the claim is a lie, refuse it
 *   unfounded    the feed is held but nobody founded it, so it names nobody yet
 */
export function handleVerdict (claim, founder) {
  if (!founder || !founder.identityRoot) return { ...claim, verdict: 'unfounded' }
  // ⛔ Compared as hex STRINGS, both lower-cased at their source — apply stores `proof.root` from
  // `attestation.js`, which returns hex, and `__founder__.identityRoot` comes from the same
  // function. A Buffer compared by identity here would pass nothing and reject everything.
  if (founder.identityRoot !== claim.root) {
    return { ...claim, verdict: 'MISMATCH', founder: founder.identityRoot }
  }
  return { ...claim, verdict: 'confirmed', founder: founder.identityRoot }
}

export class FavelaNode {
  constructor (storage, opts = {}) {
    if (typeof storage !== 'string') throw new Error('FavelaNode: a storage directory is required')
    this.storage = storage
    this.corestore = new Corestore(join(storage, 'store'))
    this.overlay = null
    this._injectedOverlay = opts.overlay || null
    this.swarm = opts.swarm || null
    this.transport = opts.transport || null
    this.peer = null
    this._feeds = new Map() // hex feed key -> Feed
    // Feeds being opened right now. ⛔ Keyed so a reader pressing twice, or a page polling, cannot
    // stack duplicate opens of the same feed.
    this._opening = new Map()
    // hex room key -> Room. ⛔ One Corestore namespace EACH: two Autobees sharing one derive the
    // same local writer and hang rather than erroring, and a node is expected to hold many rooms.
    this._rooms = new Map()
    this._optimistic = opts.optimistic !== false // the doorbell is on by default
    // Browser identities. Its own Autobee on its own Corestore namespace — see browsers.js.
    this.browsers = null
    this.avatars = null
    this.names = null
    this._invitedWatch = null
    this._answering = false
    // At most one pairing offer in flight. A second would mint a second credential and leave the
    // first dangling — an invite nobody can account for.
    this._pairing = null
    this._pairingState = null
  }

  /**
   * Rebuild the overlay's serve map from what this node already indexed.
   *
   * ⛔ hyper-overlay resolves an incoming fetch-by-oid through IN-MEMORY maps that only
   * `registerFile()` fills, and nothing rebuilds them at boot. Without this a restarted node serves
   * ONLY what it has written since it started — every other file sits on disk, indexed, with a
   * matching hash, and unreachable to peers. That cost 75+ silent hours on wj62; 0.3.9 added the
   * seed and the CONSUMER has to call it. This one never did.
   *
   * MEASURED 2026-09-07: tabertron held 9 pointers from ashatron and could read 0, stable, while
   * ashatron read its own posts fine off local disk — its daemon had been restarted minutes before.
   *
   * ⚠ Cheap by design: one index scan, no re-hashing and no file reads. `reindexOverlay()` is the
   * heavy repair for an index that is itself stale, and is deliberately not on the boot path.
   */
  async _seedOverlay () {
    if (!this.overlay || typeof this.overlay.seedFilePathsFromIndex !== 'function') return 0
    try {
      return await this.overlay.seedFilePathsFromIndex((p) => this._resolveOverlayPath(p))
    } catch {
      // A serve map that could not be rebuilt is a degraded node, not a dead one: it still reads,
      // writes and replicates logs. Refusing to boot over it would be worse.
      return 0
    }
  }

  /**
   * An overlay path back to a file under this node's storage, or null.
   *
   * ⛔ The index is DATA ON DISK. An entry carrying a traversing path must not be able to point the
   * serve map outside this storage root — that map is what gets handed to peers.
   */
  _resolveOverlayPath (p) {
    if (typeof p !== 'string' || !p.startsWith('/')) return null
    const abs = join(this.storage, p)
    const root = join(this.storage, '/')
    if (!abs.startsWith(root)) return null
    // ⚠ And it must EXIST. Entries written under the older overlay-path shape resolve to files that
    // were never there, and seeding the map with those would replace "I cannot serve this" with "I
    // will offer you a file I do not have" — a worse answer, and one the requester only discovers
    // by timing out. One stat per index entry; the scan is already reading them.
    try { return fs.existsSync(abs) ? abs : null } catch { return null }
  }

  // The records directory for one feed. Kept per-feed so a feed's bytes are never confused with
  // another's, while the OVERLAY is shared — dedup and serving are node-wide, ownership is not.
  _recordsDir (name) { return join(this.storage, 'records', name) }

  async ready () {
    fs.mkdirSync(this.storage, { recursive: true })
    this.overlay = this._injectedOverlay || new HyperOverlay(this.corestore.namespace('overlay'), {
      namespace: 'favela-records',
      destDir: join(this.storage, '.fetch')
    })
    await this.overlay.ready()
    await this._seedOverlay()

    // My own feed. Opened first and by a stable namespace so it survives a restart.
    //
    // ⛔ With a stored feed key this node JOINS that feed instead of founding one — it is somebody's
    // second device. Without one it founds its own, which is the first device of an identity. The
    // key is written only by pairing, so the two cases never have to be guessed at.
    const joining = loadFeedKey(this.storage)
    const mine = await this._open('self', joining ? b4a.from(joining, 'hex') : null)

    // ⛔ The daemon does NOT touch the vault at boot. It looks for a device credential — a proof
    // for this feed's writer key — and if there is none it starts anyway, unadmitted and not
    // writable, so that `favela link` has something to talk to. A daemon that crashed without a
    // credential would leave no way to give it one.
    // The credential is a DEVICE keypair; the writer core key is bound to it here, at boot, because
    // the writer key is only known once the feed is open.
    const cred = loadCredential(this.storage)
    if (cred) {
      await mine.addDevice(bindWriter(cred, mine.deviceKey))
      await mine.update()
    }
    // ⛔ Kept, because writing to the feed is not the only thing this device's identity is for: a
    // name CLAIM is signed by the same credential, in a different log. Null on an unlinked node,
    // and `claim` refuses loudly rather than appending something every peer would silently drop.
    this.cred = cred
    this.peer = await new Peer(mine).ready()
    // How this node proves who is knocking: a proof binding a writer core to our identity root.
    // Reads `this.cred` LIVE, so linking later (`link`) is picked up without rewiring; unlinked, it
    // refuses rather than letting a knock go out unproven (feed.js applyKnock would drop it anyway).
    this.peer.bind = (writerKeyHex) => {
      if (!this.cred) throw new Error('this device is not linked, so it cannot prove who is knocking — run `favela link`')
      return bindWriter(this.cred, writerKeyHex)
    }
    this.browsers = await new Browsers(this.corestore).ready()
    this.avatars = new Avatars(this.storage, this.overlay)
    // Files embedded in records — the same overlay, a different directory and a wider type table.
    this.files = new Files(this.storage, this.overlay)

    // The namespace this node resolves handles in.
    //
    // ⛔ The key is CONFIGURATION, stored on disk and replaceable. NAMING.md §8 #2: a default that
    // cannot be changed IS a root. Generating one on first boot makes this node the origin of ITS
    // default and nobody else's — several namespaces coexist, and joining a different one is a
    // runtime act (`joinNamespace`) rather than a file edit and a restart.
    //
    // ⚠ So "global" here means "the one you happen to have joined". Two nodes that never exchange
    // this key resolve handles in different worlds, correctly.
    await this._openNames(loadNamespace(this.storage))
    // ⛔ Founded HERE, at creation, because the op is refused once an admin exists and the only
    // thing closing that race is nobody else holding the key yet. An unlinked node cannot sign, so
    // it founds nothing and `link()` does it instead — a daemon must still start without a
    // credential or `favela link` would have nothing to talk to.
    if (cred) await this._foundNames(cred)

    // Re-open the rooms this node is in, for the same reason as the feeds below: a restart that
    // forgot them would look exactly like having been removed from every one of them.
    for (const r of loadRooms(this.storage)) {
      try { await this._openRoom(r.key, r.slot) } catch {}
    }

    // ⛔ HOLD THE BYTES OF FEEDS WE FOLLOW, before anyone asks to read them.
    //
    // MEASURED 2026-09-08: this node held five pointers from ashatron and could read none of them.
    // ashatron is a laptop, it went offline, and it was the only machine with the bytes — because a
    // record's bytes are fetched only when a reader asks, and nobody had. All five resolved the
    // moment it came back.
    //
    // Following is the decision to KEEP someone's bytes; peeking is not, which is why this reads
    // `following()` rather than every readable feed. `peer.timeline` draws the same line for the
    // same reason. See hyper-social/docs/HOLDING-AND-TELLING.md.
    this._mirror = createMirror({
      // ⛔ LEVEL 2. A record's bytes are one oid in the log; the files it NAMES are more oids inside
      // its markdown, in the SAME overlay. Without this a mirrored post reads fine and its picture
      // will not load — the outage this exists for, one level down.
      files: this.files,
      feeds: () => {
        const out = []
        for (const [name, feed] of this.peer.feeds) {
          if (this.peer.peeked.has(name) || this.peer._blocked.has(name)) continue
          out.push({ key: name, feed })
        }
        return out
      },
      // ⚠ No logger on this class, and a mirror pass must not invent one. Failures surface where
      // they already do: a record whose bytes did not arrive stays UNRESOLVED and the surface says so.
    })
    // ⛔ CREATED BEFORE THE FEEDS ARE HELD, so `hold()` can attach a watcher to each as it opens.
    // Re-open the feeds we already follow, so a restart resumes rather than forgetting.
    for (const name of await this.peer.following()) {
      try { await this.hold(name) } catch {}
    }

    // ⛔ AND THE OBSERVATION FEEDS BRIDGES VOUCHED FOR (hyper-social #82, I1): what the outside world did to this
    // user, written by a bridge and held here like any feed — read only as notifications, only while its founder is
    // the pinned root. Then, NOT awaited: any bridge remembered from before grants existed is asked once, by a
    // re-claim of the same name (idempotent at the bridge), so an existing registration picks up its feed with
    // nobody pressing anything.
    for (const o of observers(this.storage)) {
      try { await this.holdObserver(o) } catch {}
    }
    this._refreshObserverGrants().catch(() => {})

    // ⚠ NOT awaited, and NOT sufficient on its own. A first sweep must not sit inside boot. And an
    // end-to-end run on two machines showed the boot pass alone finds nothing: at boot a followed
    // feed's log has usually not replicated yet, so the real work is done by the 'update' watcher
    // attached in hold().
    this._mirror.pass().catch(() => {})
    // ⛔ AND A FLOOR UNDER IT. 0.0.40's trigger subscribed to an autobee event that only fires on
    // an interrupt, so it never fired at all — and nothing said so. A slow local sweep cannot be
    // wrong in that way: it reads a bee and stats files, and touches the network only when a record
    // is genuinely missing.
    // ⛔ A PEER ARRIVING IS AN EVENT. A record is unresolved because nobody holding its bytes is
    // online; their return is a swarm 'connection', not an absence, so it must not be discovered by
    // a clock. This is the trigger the timer was standing in for.
    if (this.swarm && typeof this.swarm.on === 'function') {
      this._onMirrorPeer = () => this._mirror.onPeer()
      this.swarm.on('connection', this._onMirrorPeer)
    }
    return this
  }

  get key () { return this.peer.feed.key }

  /** Hold a bridge's observation feed, pinned to the founder root the bridge vouched for. */
  async holdObserver (o) {
    if (!o || !o.observationFeed || !o.observerRoot) return null
    this.peer.observers.set(String(o.observationFeed).toLowerCase(), String(o.observerRoot).toLowerCase())
    return this.hold(o.observationFeed)
  }

  /**
   * Ask each remembered bridge with no grant yet for this node's observation feed, by re-claiming the same name.
   *
   * ⚠ A re-claim of the SAME name, root and feed is idempotent at the bridge (it answers with the grant). It needs
   * this device's credential to sign, and a reachable bridge; either missing just means "next boot".
   */
  async _refreshObserverGrants () {
    if (!this.cred || !this.swarm) return
    // This device's bridges.json AND the bridge the identity's reach names — so a device that never claimed anything
    // itself still asks the bridge its identity is registered on (claimTargets).
    let reach = {}
    try { reach = await this.peer.reach() } catch {}
    for (const [acct, bridgeKey] of claimTargets(this.storage, reach)) {
      const name = String(acct).split('@')[0]
      if (!name) continue
      try {
        const { rpc } = await connectBridge(this.swarm, bridgeKey)
        const { nonce } = await rpc.request('challenge', {})
        const r = await rpc.request('claim', buildClaim({ nonce, username: name, feedKey: this.key.toString('hex'), root: this.rootHex(), cred: this.cred }))
        if (r && (r.status === 200 || r.status === 202)) {
          const o = acceptObserver(this.storage, bridgeKey, r.body)
          if (o) await this.holdObserver(o)
        }
      } catch { /* unreachable or refused: asked again on the next boot */ }
    }
  }

  /**
   * Announce everything `ready()` opened.
   *
   * ⛔ `ready()` builds the overlay, and the transport needs the overlay — so the transport does not
   * exist while ready() is opening things, and every `transport.join` inside it is a no-op. Three
   * separate things have now had to be re-joined by hand afterwards (the feeds, the namespace, and
   * rooms) and each was forgotten in turn, each time producing a log that replicates for nobody.
   *
   * One method, so the next thing ready() opens is announced by construction rather than by
   * somebody remembering.
   */
  async announceAll () {
    if (!this.transport) return 0
    let n = 0
    const join = async (base) => { if (base) { try { await this.transport.join(base); n++ } catch {} } }
    await join(this.peer && this.peer.feed)
    await join(this.names)
    for (const [, feed] of this._feeds) await join(feed)
    for (const [, room] of this._rooms) await join(room)
    return n
  }

  /**
   * Open a namespace into `this.names` and announce it.
   *
   * ⚠ `stored` is `{ key, slot }` or null. A null mints one, in the legacy `names` slot — the key
   * cannot name its own slot because it does not exist until the log is open.
   */
  async _openNames (stored) {
    this.names = await new Namespace(
      this.corestore.namespace(stored ? stored.slot : 'names'),
      stored ? b4a.from(stored.key, 'hex') : null
    ).ready()
    if (!stored) saveNamespace(this.storage, { key: hex(this.names.key), slot: 'names' })
    // ⛔ Announced, or a shared registry is three private ones. This is a no-op at boot — the
    // transport is built AFTER ready() so the overlay exists first — which is why `index.js` joins
    // it explicitly, exactly as it already does for the feeds.
    if (this.transport) { try { await this.transport.join(this.names) } catch {} }
    this._invitedWatch = null
    this._watchInvited()
  }

  /**
   * Answer vouched-for asks as they arrive, without a human.
   *
   * ⛔ Re-entrancy guarded: answering APPENDS, which fires `update` again. Without the flag the
   * first invited request would recurse until the stack gave out.
   */
  _watchInvited () {
    if (!this.names || this._invitedWatch) return
    this._invitedWatch = async () => {
      if (this._answering || !this.cred || !this.names) return
      this._answering = true
      try { await this.names.answerInvited(this.cred) } catch {} finally { this._answering = false }
    }
    this.names.base.on('update', this._invitedWatch)
  }

  /** Found this namespace if it is ours to found. Idempotent: apply refuses a second founding. */
  async _foundNames (cred) {
    if (!this.names || !cred) return
    // ⛔ ONLY FOUND A NAMESPACE THIS NODE CREATED. A joined one — now the default for a fresh
    // install — has someone else as its genesis writer, so `found()` fails with "Not writable" from
    // autobee's BACKGROUND drain, which the try/catch below cannot catch: the rejection never
    // reaches the awaited path, it surfaces as an uncaught error and takes `ready()` down with it.
    //
    // ⚠ The existing `founder()` check is not enough on its own. A node that has just joined has
    // replicated nothing yet, so it sees no founder and concludes the namespace needs founding —
    // which is exactly the case that throws.
    if (!this.names.base || !this.names.base.writable) return
    await this.names.update()
    if (await this.names.founder()) return
    try { await this.names.found(cred) } catch { return }
    await this.names.update()
  }

  /**
   * Resolve handles in a different namespace from now on.
   *
   * ⛔ The one we leave stays on disk, in its own Corestore slot, and coming back finds it exactly
   * as it was. Joining somebody else's registry must never be a one-way door that destroys the one
   * you made.
   */
  async joinNamespace (keyHex) {
    if (typeof keyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(keyHex)) {
      throw new Error('a namespace key is 32 bytes of hex — ' + String(keyHex).slice(0, 16))
    }
    const key = keyHex.toLowerCase()
    if (this.names && hex(this.names.key) === key) return { key, joined: false }

    const current = loadNamespace(this.storage)
    const known = loadNamespaces(this.storage)
    // ⚠ Back to one we already hold: reuse ITS slot. Opening the same log in a second slot would
    // give this node two writer cores in one namespace — not fatal, and permanently confusing.
    //
    // ⛔ AND THE INDEX IS CONSULTED, not just the current one. The namespace this node FOUNDED lives
    // in the legacy slot `names`, which `namespaceSlot(key)` cannot produce — so leaving it and
    // coming back later computed `names:<key>`, opened a SECOND log, and left every handle claimed
    // there resolving from a slot this node was no longer using. The claims were never lost; they
    // were simply somewhere else, which is worse because it looks like loss.
    const slot = slotFor(key, { current, known })

    if (this.names) {
      if (this.transport) { try { await this.transport.leave(this.names) } catch {} }
      await this.names.close()
      this.names = null
    }
    await this._openNames({ key, slot })
    saveNamespace(this.storage, { key, slot })
    // Remembered so it can be returned to by name rather than by a key someone has to have kept.
    saveNamespaces(this.storage, [...known.filter((n) => n.key !== key), { key, slot }])
    return { key, joined: true }
  }

  async _open (namespace, key, recordsName = namespace, bootHead = null) {
    const feed = new Feed(this.corestore.namespace(namespace), key, {
      // `base` is what makes an overlay path resolvable back to a file at boot — see
      // `_seedOverlay` and `RecordStore._overlayPath`.
      store: new RecordStore(this._recordsDir(recordsName), { overlay: this.overlay, base: this.storage }),
      optimistic: this._optimistic,
      bootHead // see Feed's constructor: without one, fast-forward is off rather than stalling
    })
    await feed.ready()
    // ⛔ Guarded, like every other transport call in this file. It was the one that was not, so a
    // swarm that refused a join threw AFTER the feed was open — leaving it draining, untracked and
    // never closed, which then blew up as "RocksDB session is closed" when the store shut down.
    //
    // ⚠ A feed with no join is still a usable feed. It simply does not replicate, which is a
    // degraded state and not a failure to open.
    if (this.transport) { try { await this.transport.join(feed) } catch {} }
    // ⛔ Repair what the boot seed could not: entries written under the older overlay-path shape
    // resolve to nothing and stay unservable for ever. ⚠ NOT awaited — it hashes every record file,
    // and making a feed open wait on that is the cold-start cost the overlay keeps off its own boot
    // path for the same reason.
    if (feed.store && typeof feed.store.reregisterLocal === 'function') {
      feed.store.reregisterLocal().catch(() => {})
    }
    return feed
  }

  // Hold someone else's feed: open it read-only under its own namespace and hand it to the Peer.
  /**
   * Resolve a handle all the way — name → identity root → feed — and say how far it got.
   *
   * ⛔ NAMING.md §3 resolves a name to an identity ROOT and only then to a room key, and the two
   * halves live in different places on purpose. `Namespace.resolve` proves WHO made the claim, from
   * the claim's own bytes: apply can do that with no state at all. What it cannot do is prove that
   * identity owns the feed the claim points at — the namespace is a separate log with no access to
   * a feed's `__founder__` — so a stranger may still aim a name at someone else's feed under their
   * OWN root. That lie is only detectable HERE, where the feed is actually held.
   *
   * Four verdicts, and the difference between the middle two is the whole point:
   *
   *   confirmed    the feed's founder IS the claim's anchor — the handle means what it says
   *   MISMATCH     the feed is founded by someone else. Refuse it. This is the attack.
   *   unheld       we do not hold the feed, so there is nothing to check against yet
   *   unfounded    the feed is held but has no founder — an empty feed names nobody
   *
   * ⚠ `unheld` deliberately does NOT go and fetch the feed. Holding someone's feed is a decision
   * with storage and replication behind it, and making a name lookup take it silently would turn
   * reading a byline into following a stranger. NAMING.md §3.2: fail visibly, never guess.
   */
  /**
   * A bare NAME to the one person who holds it, or null.
   *
   * ⛔ Only when there is exactly ONE confirmed claimant. NAMING.md §3.2: an unresolvable name must
   * fail visibly rather than have a namespace guessed for it, and "the first alice" is that guess
   * wearing a helpful face. Two alices means `@alice` names nobody, and it stays plain text.
   */
  async resolveName (name) {
    await this.names.update()
    let candidates = []
    try { candidates = await this.names.candidates(name) } catch { return null }
    const confirmed = []
    for (const c of candidates) {
      const judged = await this._judge(c)
      if (judged.verdict === 'confirmed') confirmed.push(judged)
    }
    return confirmed.length === 1 ? confirmed[0] : null
  }

  /** A handle OR a bare name — what a person actually types after an @. */
  async resolveMention (token) {
    return (await this.resolveHandle(token)) || this.resolveName(token)
  }

  async resolveHandle (handle) {
    await this.names.update()
    const claim = await this.names.resolve(handle)
    if (!claim) return null
    return this._judge(claim)
  }

  /**
   * Every handle in the namespace, each carrying its verdict. `query` narrows it to a search.
   *
   * ⛔ The verdict is attached HERE rather than by each caller, because every caller that forgets
   * it renders a stranger's claim as somebody's name. There is exactly one place that decides.
   */
  async handles (query = null) {
    await this.names.update()
    const claims = query ? await this.names.search(query) : await this.names.list()
    const out = []
    for (const c of claims) out.push(await this._judge(c))
    return out
  }

  /**
   * Confirmed handles only, as `feed -> handle`. What a byline may safely use.
   *
   * ⛔ A claim points at a feed; it does not prove the feed agrees. So an UNCONFIRMED claim must
   * never reach a byline — rendering one would let anyone rename anyone by claiming a name at their
   * feed key, which is the whole attack, arriving through the display layer instead of the log.
   *
   * ⚠ `unheld` is excluded too, not just MISMATCH. We cannot check a feed we do not hold, and
   * "unchecked" is not "fine" — that conflation is how a check becomes decorative.
   */
  async confirmedHandles () {
    const out = {}
    for (const c of await this.handles()) if (c.verdict === 'confirmed') out[c.feed] = c.handle
    return out
  }

  /**
   * Attach a verdict to one claim: does the feed it points at agree?
   *
   * ⚠ Does NOT fetch a feed it does not already hold. Holding someone's feed is a decision with
   * storage and replication behind it, and making a name lookup take it silently would turn reading
   * a byline into following a stranger. NAMING.md §3.2: fail visibly, never guess.
   */
  async _judge (claim) {
    const feed = this._feeds.get(claim.feed) ||
      (claim.feed === hex(this.key) ? this.peer.feed : null)
    if (!feed) return { ...claim, verdict: 'unheld' }
    return handleVerdict(claim, await feed.founder())
  }

  // ── rooms ─────────────────────────────────────────────────────────────────────────────────────

  async _openRoom (keyHex, slot) {
    const key = String(keyHex).toLowerCase()
    if (this._rooms.has(key)) return this._rooms.get(key)
    const room = await new Room(this.corestore.namespace(slot), b4a.from(key, 'hex')).ready()
    this._rooms.set(key, room)
    // ⛔ Announced, or it is a private log wearing the name of a shared one — the exact defect the
    // namespace had until today.
    if (this.transport) { try { await this.transport.join(room) } catch {} }
    return room
  }

  _persistRooms () {
    saveRooms(this.storage, [...this._rooms.keys()].map((key) => ({ key, slot: 'room:' + key })))
  }

  /** Make a room. This node's identity founds it, so it is the room's first admin. */
  async createRoom ({ initialMembers = [] } = {}) {
    if (!this.cred) throw new Error('creating a room needs this device\'s credential — run `favela link`')
    // ⚠ The key is not known until the log is open, so the slot cannot be named after it. A fresh
    // slot per creation keeps each room's writer its own.
    const slot = 'room:new:' + Date.now().toString(16) + Math.random().toString(16).slice(2, 8)
    const room = await new Room(this.corestore.namespace(slot), null).ready()
    await room.found(this.cred, { initialMembers })
    await room.update()
    const key = hex(room.key)
    this._rooms.set(key, room)
    if (this.transport) { try { await this.transport.join(room) } catch {} }
    saveRooms(this.storage, [...loadRooms(this.storage).filter((r) => r.key !== key), { key, slot }])
    return { key }
  }

  /**
   * Join a room by key.
   *
   * ⚠ Joined is not admitted. Holding the key lets this node replicate and read; becoming a MEMBER
   * is a decision somebody already inside makes, and it has to be, because a grant is capped at the
   * weight of the block carrying it.
   */
  async joinRoom (keyHex) {
    if (typeof keyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(keyHex)) {
      throw new Error('a room key is 32 bytes of hex')
    }
    const key = keyHex.toLowerCase()
    if (this._rooms.has(key)) return { key, joined: false }
    await this._openRoom(key, 'room:' + key)
    saveRooms(this.storage, [...loadRooms(this.storage), { key, slot: 'room:' + key }])
    return { key, joined: true }
  }

  /** Stop replicating a room. ⚠ It does not delete one anybody else is in, and it cannot. */
  async leaveRoom (keyHex) {
    const key = String(keyHex).toLowerCase()
    const room = this._rooms.get(key)
    if (!room) return { key, left: false }
    if (this.transport) { try { await this.transport.leave(room) } catch {} }
    await room.close()
    this._rooms.delete(key)
    saveRooms(this.storage, loadRooms(this.storage).filter((r) => r.key !== key))
    return { key, left: true }
  }

  room (keyHex) { return this._rooms.get(String(keyHex).toLowerCase()) || null }

  /** This node's identity root, or null when it cannot sign. What a room asks about membership. */
  rootHex () {
    if (!this.cred) return null
    try { return readAttested(this.cred.proof, undefined).root } catch { return null }
  }

  async rooms () {
    const out = []
    for (const [key, room] of this._rooms) {
      await room.update()
      out.push({ key, config: await room.config(), members: (await room.members()).length })
    }
    return out
  }

  async hold (feedKey, bootHead = null) {
    const id = hex(feedKey)
    if (this._feeds.has(id)) return this._feeds.get(id)
    const feed = await this._open('feed:' + id, b4a.from(id, 'hex'), id, bootHead)
    this._feeds.set(id, feed)
    // Mirror this feed's bytes as its log advances — the trigger the boot pass cannot be.
    if (this._mirror) this._mirror.watch(feed.base)
    this.peer.feeds.set(id, feed)
    return feed
  }

  /**
   * Hold a feed to READ it — no follow record, and nothing written to theirs.
   *
   * ⛔ An explicit act, never a side effect of a lookup. `resolveHandle` deliberately refuses to
   * fetch a feed it does not hold, for exactly this reason: holding is a storage and replication
   * commitment, and taking it silently would turn reading a byline into following a stranger.
   *
   * ⚠ It does not survive a restart. `ready()` re-opens only what `following()` names, so a peek
   * lasts for the life of the daemon and then evaporates — which is the right default and costs no
   * new state. The BYTES stay on disk until something collects them; that is a separate question.
   */
  async peek (feedKey) {
    const id = hex(feedKey)
    if (this._feeds.has(id) || id === hex(this.key)) return { feed: id, status: 'held' }
    if (!this._opening.has(id)) {
      // ⛔ NOT awaited. Opening a feed we have never seen means an autobee on a remote key and a
      // swarm join, and that is a DHT round trip — inside the request it is indistinguishable from
      // a hung daemon. `pairOffer` learned the same thing and answers first too.
      //
      // ⚠ The failure path clears the entry. A peek that threw and stayed in this map would leave
      // the page saying "fetching…" for ever with no way to retry.
      const work = (async () => {
        const feed = await this.hold(feedKey)
        this.peer.peek(feedKey, feed)
      })().catch(() => {}).finally(() => this._opening.delete(id))
      this._opening.set(id, work)
    }
    return { feed: id, status: 'opening' }
  }

  /** The in-flight open for a feed, if there is one. Awaiting it is optional and never required. */
  opening (feedKey) { return this._opening.get(hex(feedKey)) || Promise.resolve() }

  /**
   * What this node has of a feed — synchronous, because a page asks it on every render.
   *
   * ⛔ Four states, not two. "Not held" and "still arriving" look identical to a reader and are
   * completely different facts: one means offer to look, the other means wait. Collapsing them is
   * how a page ends up claiming somebody posted nothing when their feed simply has not landed.
   */
  holding (feedKey) {
    const id = hex(feedKey)
    return {
      held: this._feeds.has(id) || id === hex(this.key),
      peeked: this.peer.peeked.has(id),
      following: this.peer._follows ? this.peer._follows.has(id) : false,
      opening: this._opening.has(id),
      blocked: this.peer._blocked ? this.peer._blocked.has(id) : false
    }
  }

  // Follow = record it AND hold it. The two are separable in Peer on purpose; here they travel
  // together, because a node that recorded a follow and did not open the feed would silently show
  // an empty timeline.
  async follow (feedKey) {
    const feed = await this.hold(feedKey)
    await this.peer.follow(feedKey, feed)
    await this.peer.update()
    return hex(feedKey)
  }

  async unfollow (feedKey) {
    const id = hex(feedKey)
    await this.peer.unfollow(id)
    const feed = this._feeds.get(id)
    if (feed) {
      if (this.transport) { try { await this.transport.leave(feed) } catch {} }
      this._feeds.delete(id)
      await feed.close()
    }
    await this.peer.update()
  }

  /**
   * Link this device: record the proof and admit ourselves.
   *
   * Idempotent — admission is a no-op once the roster holds this device, and re-admitting after a
   * revocation is refused by apply rather than here. The proof is stored first so a restart keeps
   * the link even if the append does not land.
   */
  async link (cred) {
    saveCredential(this.storage, cred)
    await this.peer.feed.addDevice(bindWriter(cred, this.peer.feed.deviceKey))
    await this.peer.update()
    // ⛔ Kept in step: a node that booted unlinked has an unfounded namespace, and this is the first
    // moment it can sign anything. Leaving it admin-less would mean nobody could ever be admitted
    // to carry it.
    this.cred = cred
    await this._foundNames(cred)
    return { device: this.peer.feed.deviceKey, devices: await this.peer.feed.devices() }
  }

  /**
   * Offer this device's credential to another machine.
   *
   * ⛔ Mints a NEW credential from ours — the vault is not opened and the root does not travel. The
   * invite is returned at once and the wait continues in the background, because a socket request
   * that blocked for five minutes would look like a hung daemon.
   */
  async pairOffer (opts = {}) {
    if (this._pairingState && this._pairingState.status === 'waiting') {
      throw new Error('an invite is already outstanding — `favela pair --cancel` first')
    }
    const cred = loadCredential(this.storage)
    if (!cred) throw new Error('this device is not linked, so it has nothing to pass on — run `favela link`')

    this._pairing = new Pairing()
    const { invite, joined } = await this._pairing.offer(cred, { ...opts, feedKey: hex(this.key) })
    this._pairingState = { status: 'waiting', invite }

    joined
      .then((r) => { this._pairingState = { status: 'linked', devicePublicKey: r.devicePublicKey } })
      .catch((err) => { this._pairingState = { status: 'failed', error: err.message } })
      // ⛔ close() DRAINS before destroying — see pairing.js. Closing eagerly here would cut the
      // frame that carries the credential and the other side would time out believing nothing
      // happened, while this side reported success.
      .finally(async () => { try { await this._pairing.close() } catch {}; this._pairing = null })

    return { invite }
  }

  pairStatus () { return this._pairingState || { status: 'idle' } }

  async pairCancel () {
    if (this._pairing) { try { await this._pairing.close() } catch {}; this._pairing = null }
    this._pairingState = { status: 'cancelled' }
    return this._pairingState
  }

  /**
   * Take an invite from another machine and link this one with what arrives.
   *
   * ⛔ Pairing happens AT INIT — a phone joining an identity it has never written under. So the
   * normal case is adopting the offered feed, and the guard exists for the case that is not init:
   * a node that has already founded its own feed and written to it would lose that log.
   *
   * The check is for RECORDS, not for a founder. An unlinked node has an empty unfounded feed
   * (opening one is not founding it), so requiring "no founder" would refuse every legitimate
   * pairing; requiring "nothing written" refuses exactly the case that would lose something.
   */
  async pairAccept (invite) {
    const p = new Pairing()
    try {
      const cred = await p.accept(invite)

      if (cred.feedKey && cred.feedKey !== hex(this.key)) {
        const refusal = adoptionRefusal((await this.peer.feed.list()).length, cred.feedKey, hex(this.key))
        if (refusal) throw new Error(refusal)
        saveFeedKey(this.storage, cred.feedKey)
        saveCredential(this.storage, cred)
        // ⚠ The feed is opened at boot, so adopting one means reopening. Reported rather than done
        // in place: swapping the log under a live Peer is the kind of half-applied state this
        // codebase refuses everywhere else.
        return { adopted: cred.feedKey, restart: true, label: cred.label }
      }

      // The same `link` the vault path uses. Nothing downstream is pairing-specific.
      return { ...(await this.link(cred)), label: cred.label }
    } finally {
      try { await p.close() } catch {}
    }
  }

  async close () {
    // Stop the mirror before anything it touches is torn down — a pass mid-fetch holding a
    // corestore session while the store closes throws into nobody's hands.
    if (this._mirror) this._mirror.stop()
    if (this._onMirrorPeer && this.swarm) { try { this.swarm.off('connection', this._onMirrorPeer) } catch {} }
    // ⚠ Settle in-flight peeks FIRST. A background open still holding a corestore session while
    // this tears the store down throws "RocksDB session is closed" into nobody's hands — the open
    // has no caller left to reject to. In production these settle promptly: `hold()` awaits a feed
    // open and a swarm join, and the join no longer waits on the DHT.
    if (this._opening.size) { try { await Promise.allSettled([...this._opening.values()]) } catch {} }
    if (this._pairing) { try { await this._pairing.close() } catch {}; this._pairing = null }
    if (this.names) { try { await this.names.close() } catch {} }
    if (this.browsers) { try { await this.browsers.close() } catch {} }
    if (this.transport) { try { await this.transport.destroy() } catch {} }
    for (const [, room] of this._rooms) { try { await room.close() } catch {} }
    for (const [, feed] of this._feeds) { try { await feed.close() } catch {} }
    if (this.peer) { try { await this.peer.feed.close() } catch {} }
    if (this.overlay && this.overlay.close) { try { await this.overlay.close() } catch {} }
    // ⛔ LAST, and it was missing. Closing every session above does not release the store's RocksDB
    // lock, so a node that closed cleanly still owned its storage directory: reopening it — a
    // restart, or a test that boots twice — failed with "File descriptor could not be locked".
    if (this.corestore) { try { await this.corestore.close() } catch {} }
  }
}