OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / rpc.js

816 lines · javascript ref:
// The daemon's RPC surface — newline-delimited JSON over a unix socket.
//
// Deliberately the smallest thing that works. hyper-svc is the ecosystem answer and brings
// transports, auth contexts and a documented half-open socket leak; none of that earns its place
// while the only client is a CLI on the same machine. The socket is owner-gated by the filesystem,
// which is the same trust boundary pgv3's UDS listener relies on.
//
// One frame per line: request `{id, cmd, args}` → response `{id, ok, value}` or `{id, ok:false, error}`.

import net from '#node:net'
import b4a from 'b4a'
import fs from '#node:fs'
import { join } from '#node:path'
import { Peer, LIKE_VALUE } from './peer.js'
import { linkMentions } from './mentions.js'
import { graphOf } from './graph.js'
import { recordName } from './name.js'
// ⛔ ONE ladder, owned by the record vocabulary that defines it (record-vocabulary §3.5).
import { makePointer, AUTHOR, selectLevel, planLevels, derivedBy } from 'favela-markdown/lib/levels.js'
import { available as ffmpegAvailable, probe, encodeLevel } from './transcode.js'

/**
 * Serve `dispatch` over a unix socket.
 *
 * ⛔ `call` IS THE INTERFACE, and it is supplied rather than assumed. `dispatch(node, cmd, a)` is
 * what this daemon can do; a COMPOSITION that holds this node alongside something else can do more,
 * and it must be able to offer the same set here as it offers over any other adapter. The Android
 * worklet already composes exactly that way and has no socket to serve it on (SELinux refuses an
 * unprivileged app a `sock_file`); a desktop host wants the same composition WITH a socket, for
 * `favela-bridge` and the CLI.
 *
 * ⚠ This teaches favela-peer nothing about what those extra verbs are, or that any exist. The
 * default is unchanged, so every existing caller behaves exactly as before.
 */
export function createServer (node, sockPath, { onError = () => {}, call = null } = {}) {
  const handle = call || ((cmd, args) => dispatch(node, cmd, args))
  // A stale socket from a crashed daemon would make bind fail with EADDRINUSE and look like
  // "already running". Removing it is safe ONLY because the caller has already established that no
  // daemon holds it — see index.js, which connects first and refuses to start if one answers.
  try { fs.unlinkSync(sockPath) } catch {}

  const server = net.createServer((socket) => {
    let buf = ''
    socket.on('error', onError)
    socket.on('data', async (chunk) => {
      buf += chunk.toString()
      let i
      while ((i = buf.indexOf('\n')) !== -1) {
        const line = buf.slice(0, i)
        buf = buf.slice(i + 1)
        if (!line.trim()) continue
        let req
        try { req = JSON.parse(line) } catch { continue }
        let res
        try {
          res = { id: req.id, ok: true, value: await handle(req.cmd, req.args || {}) }
        } catch (err) {
          res = { id: req.id, ok: false, error: (err && err.message) || String(err) }
        }
        try { socket.write(JSON.stringify(res) + '\n') } catch {}
      }
    })
  })
  server.listen(sockPath)
  return server
}

/**
 * Resolve bare `@handle` into real mentions before a body is written.
 *
 * ⛔ Applied on the WRITE path, for every client. A surface doing it would leave the CLI and any
 * other writer without it, and "you can @ someone in this one app" is not the feature.
 *
 * ⚠ It changes what the author typed, and that is the point — a bare @name is plain text for ever
 * otherwise. It is conservative about it: confirmed handles only, prose only, never inside code and
 * never inside an existing link.
 */
const mentioned = (node, body) =>
  linkMentions(String(body || ''), (token) => node.resolveMention(token))

// Every command is a thin pass-through. Business rules live in Peer and apply — a handler that
// enforced something would be a second place to look for the rule.
// ⛔ A write from an unadmitted device is DROPPED BY APPLY, deterministically and silently. The op
// appends, the CLI gets a name back, and the record never appears anywhere — which is exactly the
// "it returned, so it worked" confusion this codebase refuses at every other layer.
//
// So the socket refuses the write instead. The check is here rather than in Peer because Peer's job
// is to describe the feed, and apply's job is to be the authority; this is the door.
const MUTATIONS = new Set(['post', 'post-delete', 'follow', 'unfollow', 'like', 'unlike', 'reply',
  'knock', 'admit', 'dismiss', 'unlink', 'react', 'unreact', 'repost', 'unrepost', 'block', 'unblock',
  // ⚠ The namespace ops that SIGN. `namespace-join` is not here: pointing this node at another
  // registry needs no credential, and a node that cannot yet sign must still be able to look.
  'namespace-request', 'namespace-admit', 'namespace-grant', 'namespace-revoke',
  'namespace-invite', 'namespace-uninvite',
  // Room ops act through this device's writer core (ROOM-CONTRACT v2): an unlinked device has none.
  'room-create', 'room-post', 'room-config', 'room-role', 'room-remove', 'room-status', 'room-invite',
  'room-uninvite', 'room-join-invite', 'room-accept', 'room-reject', 'room-add-device', 'room-delete',
  'room-flag', 'room-list', 'room-delist', 'dm',
  // ⛔ Changing WHERE this identity publishes is an identity act, not a display preference.
  // `set-profile` is not here because a self-label asserts nothing; turning on a network puts words
  // under this identity somewhere that does not forget, so it requires an ATTESTED device — the
  // same gate as posting. A browser cannot do it without one, which is the whole point of the
  // browser being a controller rather than a device (see browsers.js).
  'set-reach',
  // ⛔ Asking a bridge to carry this identity puts a name for it on somebody's domain, which is the
  // same class of act as turning a network on — so it needs an ATTESTED device, not merely a
  // signed-in browser.
  'bridge-claim', 'bridge-release', 'bridge-front'])

/**
 * The room, or a refusal that says which of the two possible things is wrong.
 *
 * ⚠ "not in that room" and "that room does not exist" look identical from outside and are fixed by
 * completely different things — one by a key, the other by nothing.
 */
// ⛔ UNDER THE NODE'S OWN STORAGE, never `~/.hyperident`. A phone has no home directory, and a
// daemon that guesses one is the same defect as a binary assumed to be on PATH. The node already
// knows where its state lives, so the vault goes beside it and travels with it.
const vaultPathFor = (node) => join(node.storage, 'identity', 'vault')

const b4aOf = (hex) => b4a.from(hex, 'hex')

function requireRoom (node, key) {
  const r = node.room(key)
  if (!r) throw new Error('this node is not in room ' + String(key).slice(0, 16) + '… — join it first')
  return r
}

async function requireLinked (peer) {
  const devices = await peer.feed.devices()
  if (devices.includes(peer.feed.deviceKey)) return
  throw new Error('this device is not admitted to its own feed, so every write would be silently ' +
    'dropped by apply. Run `favela link` to attest it.')
}

export async function dispatch (node, cmd, a) {
  const peer = node.peer
  if (MUTATIONS.has(cmd)) await requireLinked(peer)
  switch (cmd) {
    case 'whoami':
      return {
        feed: node.key.toString('hex'),
        device: peer.feed.deviceKey,
        writable: peer.feed.writable,
        // ⛔ Reported, because "not writable" and "not linked" look identical from outside and are
        // fixed by completely different things.
        linked: (await peer.feed.devices()).includes(peer.feed.deviceKey)
      }
    // Admit this device with a chain proof for its writer key. The CLI mints it; the daemon never
    // opens the vault itself.
    // ⛔ MINT — create this identity, or restore it from a recovery phrase. The counterpart of
    // `pair-accept`: pairing needs another device AWAKE, this needs nobody, and until it existed the
    // origin of every favela identity sat outside the product in a shell a handset does not have.
    //
    // ⛔ THE VAULT LIVES UNDER THE NODE'S STORAGE, not `~/.hyperident`. A phone has no home directory,
    // and defaulting to one is the same class of bug as assuming a binary is on PATH — the daemon
    // already knows where its own state goes, so it says.
    //
    // ⚠ THE MNEMONIC COMES BACK ONCE AND IS NEVER STORED IN THE CLEAR when a passphrase is given.
    // The caller shows it and forgets it; nothing here writes it anywhere but the sealed vault.
    // Re-asking is impossible by design, which is what makes "a stolen device yields a locked vault"
    // true rather than aspirational.
    case 'mint-identity': {
      const { mintIdentity } = await import('./mint.js')
      const r = await mintIdentity({
        vaultPath: vaultPathFor(node),
        passphrase: a.passphrase || null,
        mnemonic: a.mnemonic || null
      })
      // Linked immediately: a minted identity that is not attested to this device would leave the node
      // holding a vault it cannot write with, which reads as a half-finished ceremony with no way back.
      await node.link({
        keyPair: {
          publicKey: b4aOf(r.credential.publicKey),
          secretKey: b4aOf(r.credential.secretKey)
        },
        proof: r.credential.proof
      })
      return { mnemonic: r.mnemonic, device: r.credential.publicKey, restored: !!a.mnemonic }
    }

    case 'link': {
      if (!a.credential || !a.credential.publicKey || !a.credential.secretKey || !a.credential.proof) {
        throw new Error('link needs { credential: { publicKey, secretKey, proof } }')
      }
      const b4a = await import('b4a')
      return node.link({
        keyPair: {
          publicKey: b4a.default.from(a.credential.publicKey, 'hex'),
          secretKey: b4a.default.from(a.credential.secretKey, 'hex')
        },
        proof: a.credential.proof
      })
    }
    case 'devices': return {
      // { writer, device } pairs — `unlink` takes the DEVICE key, so the listing has to show it.
      roster: await peer.feed.roster(),
      revoked: await peer.feed.revokedDevices(),
      founder: await peer.feed.founder()
    }
    case 'unlink': { await peer.feed.removeDevice(a.device); await peer.update(); return { revoked: a.device } }
    case 'post': {
      // ⛔ MINTED HERE, NEVER TAKEN FROM THE CALLER. This was `a.name || String(Date.now())`:
      // a millisecond clock collides, and accepting `a.name` made a collision CHOOSABLE. See lib/name.js.
      const name = recordName()
      await peer.feed.put('post', name, {
        frontmatter: { createdAt: new Date().toISOString() },
        body: await mentioned(node, a.body)
      })
      await peer.update()
      return { name }
    }
    // ⛔ DELETE MY OWN post or reply — a TOMBSTONE, like every other undo here and like keet's message
    // delete, so every peer and both bridges can tell "deleted" from "not replicated yet". The name is
    // looked up in MY feed only: there is no way to name somebody else's record here.
    case 'post-delete': {
      const name = String(a.name || '')
      for (const collection of ['post', 'reply']) {
        const r = await peer.feed.resolve(collection, name)
        if (r.status === 'deleted') return { deleted: name, collection, already: true }
        if (r.pointer) {
          await peer.feed.del(collection, name)
          await peer.update()
          return { deleted: name, collection, already: false }
        }
      }
      throw new Error('no post or reply of yours is named ' + JSON.stringify(name))
    }
    // My positively-deleted posts and replies — what the bsky projection retracts on (favela-bridge).
    case 'deleted-own': {
      await peer.update()
      return { post: await peer.feed.tombstones('post'), reply: await peer.feed.tombstones('reply') }
    }
    case 'timeline':
      await peer.update()
      return withLikes(peer, await peer.timeline({ limit: a.limit || 50 }))
    // One author's posts. ⛔ Not a filtered timeline: the timeline excludes peeked feeds, so
    // filtering it could never render the page of somebody you had only peeked.
    // ⛔ MY OWN edges, with their targets intact — what a bridge needs to project a like or a reply.
    // `reactions`/`likes` answer "who reacted to THIS", which is the page's question; this answers
    // "what have I reacted TO", which is the projector's.
    case 'edges-by': {
      await peer.update()
      return peer.ownEdges(String(a.collection || ''))
    }
    case 'posts-by': {
      await peer.update()
      return withLikes(peer, await peer.postsBy(a.feed))
    }
    // Do I hold this feed, and is it a follow or a peek? What a person page needs to know which
    // buttons to offer.
    case 'holding': return node.holding(String(a.feed || ''))
    case 'follow': return { feed: await node.follow(a.feed) }
    // ⛔ Read someone without following them. Not in MUTATIONS: it writes nothing anywhere, so a
    // device that cannot yet write to its own feed can still read a stranger's page.
    case 'peek': return node.peek(a.feed)
    // ⛔ Stop RENDERING a feed. It writes nothing to theirs and cannot stop them doing anything —
    // see peer.js. The surface must say that, or the button promises more than it does.
    case 'block': { await peer.block(a.feed); await peer.update(); return { blocked: a.feed } }
    case 'unblock': { await peer.unblock(a.feed); await peer.update(); return { unblocked: a.feed } }
    case 'blocked': return { blocked: [...await peer.blocked()] }
    case 'unfollow': { await node.unfollow(a.feed); return { feed: a.feed } }
    case 'following': return peer.following()
    case 'missing': return peer.missing()
    case 'like': { await peer.like(a.target); await peer.update(); return { ok: true } }
    case 'unlike': { await peer.unlike(a.target); await peer.update(); return { ok: true } }
    // ⛔ `like` stays its own command rather than becoming `react value=like`. It is the one value
    // both target networks can carry, so a caller that means exactly that should be able to say so.
    case 'react': { await peer.react(a.target, a.value); await peer.update(); return { ok: true } }
    case 'unreact': { await peer.unreact(a.target, a.value); await peer.update(); return { ok: true } }
    // Everyone's reactions on ONE target — a scan across held feeds, so it takes a target.
    case 'repost': { await peer.repost(a.target); await peer.update(); return { ok: true } }
    case 'unrepost': { await peer.unrepost(a.target); await peer.update(); return { ok: true } }
    // Posts naming me. ⚠ A scan of what this node holds — see peer.js. It takes no target because
    // the only root this node can prove is its own.
    // ⛔ A projection, not an inbox. Nothing was delivered to produce this — see peer.js.
    case 'notifications': {
      await peer.update()
      return { notifications: await peer.notifications({ limit: a.limit || 100 }) }
    }
    // ⛔ A projection of the SUBSTRATE. Its value is diagnostic: four states that currently look
    // identical from outside — see graph.js.
    case 'graph': { await peer.update(); return graphOf(peer) }
    case 'mentions': {
      await peer.update()
      return withLikes(peer, await peer.myMentions())
    }
    case 'reposts': {
      await peer.update()
      return { reposts: (await peer.repostsOf(a.target)).map((r) => ({ author: r.author })) }
    }
    case 'reactions': {
      await peer.update()
      const rows = await peer.reactionsOn(a.target)
      return { reactions: rows.map((r) => ({ author: r.author, value: r.value })) }
    }
    case 'likes': return (await peer.likesOn(a.target)).map((r) => view(r))
    // Have I liked it? Asked by a page that renders ONE post and has no timeline to stamp.
    case 'liked': return { liked: await peer.likedByMe(a.target) }
    case 'reply': {
      // ⛔ MINTED HERE, NEVER TAKEN FROM THE CALLER. This was `a.name || String(Date.now())`:
      // a millisecond clock collides, and accepting `a.name` made a collision CHOOSABLE. See lib/name.js.
      const name = recordName()
      await peer.reply(a.target, await mentioned(node, a.body), name)
      await peer.update()
      return { name }
    }
    case 'replies': return withLikes(peer, await peer.repliesTo(a.target))
    case 'search': {
      await peer.update()
      const r = await peer.search(a.query, { collection: a.collection || null, limit: a.limit || 25 })
      // People and posts are different answers to one query, so they are returned apart rather than
      // interleaved — a reader searching "stortron" may mean either, and the UI can show both.
      return { results: await withLikes(peer, r.results), unreadable: r.unreadable, people: await peer.searchProfiles(a.query) }
    }
    case 'knocks': return peer.knocks()
    case 'knock': { await peer.feed.knock(a.feed, peer._binder()); return { ok: true } }
    case 'admit': { await peer.admitKnock(a.feed, await node.hold(a.feed)); await peer.update(); return { ok: true } }
    case 'dismiss': { await peer.dismissKnock(a.feed); await peer.update(); return { ok: true } }
    case 'directory': return peer.directory()

    // ── rooms ───────────────────────────────────────────────────────────────────────────────
    //
    // ⛔ A room is the unit of scope, audience, governance and history at once (SOCIAL-MODEL §3).
    // A feed is the degenerate case of one — which is why these are not "group feeds".
    case 'room-create': return node.createRoom()
    case 'room-join': return node.joinRoom(a.key)
    case 'room-leave': return node.leaveRoom(a.key)
    case 'rooms': return { rooms: await node.rooms() }
    case 'room': {
      const r = requireRoom(node, a.key)
      await r.update()
      const me = node.rootHex()
      const role = me ? await r.roleOf(me) : null
      return {
        key: a.key,
        config: await r.settings(),
        members: await r.members(),
        // ⚠ Reported, because "I am in this room" and "I am a MEMBER of it" are different facts and
        // only the second lets you write. Holding the key gets you the first.
        role,
        // ⛔ The identity being a member is not THIS device being able to write: a member's second
        // device has the role and no bound core until it runs room-add-device.
        bound: (await r.devices()).some((d) => d.writer === r.writer),
        // Join requests and invitations are what moderators act on; others see none.
        requests: role === 'moderator' || role === 'admin' ? await r.requests() : [],
        invites: role === 'moderator' || role === 'admin' ? await r.invites() : [],
        listings: await r.listings(),
        messages: await r.messages()
      }
    }
    // ── ROOM-CONTRACT v2: every op is carried by THIS node's writer core, which is the credential ──
    case 'room-post': { const r = requireRoom(node, a.key); const name = await r.post(a.body, node.cred); await r.update(); return { name } }
    case 'room-config': { const r = requireRoom(node, a.key); await r.setSetting(a.field, a.value); await r.update(); return { ok: true } }
    case 'room-role': { const r = requireRoom(node, a.key); await r.setRole(a.member, a.role); await r.update(); return { ok: true } }
    // Removing yourself is leaving (reversible by invitation); a moderator removing you is permanent.
    case 'room-remove': { const r = requireRoom(node, a.key); await r.removeMember(a.member || node.rootHex()); await r.update(); return { ok: true } }
    case 'room-status': { const r = requireRoom(node, a.key); await r.setStatus(a.member, { muted: a.muted, banned: a.banned }); await r.update(); return { ok: true } }
    // ⛔ Joining is by INVITATION (v2). The token carries the invitation's signing secret: hand it over
    // a private channel; anyone holding it can join until it is revoked.
    case 'room-invite': { const r = requireRoom(node, a.key); const inv = await r.invite({ role: a.role || 'peer', requiresConfirmation: !!a.requiresConfirmation }); await r.update(); return inv }
    case 'room-uninvite': { const r = requireRoom(node, a.key); await r.uninvite(a.id); await r.update(); return { ok: true } }
    case 'room-join-invite': {
      // Opens the room if this node does not hold it yet, then joins on THIS node's own core.
      await node.joinRoom(a.key).catch(() => {})
      const r = requireRoom(node, a.key)
      await r.join(a.token, node.cred)
      await r.update()
      return { key: a.key, writer: r.writer }
    }
    case 'room-accept': { const r = requireRoom(node, a.key); await r.acceptJoin(a.writer); await r.update(); return { accepted: a.writer } }
    case 'room-reject': { const r = requireRoom(node, a.key); await r.rejectJoin(a.writer); await r.update(); return { rejected: a.writer } }
    // A member's other device: binds THIS node's core to the identity already in the room.
    case 'room-add-device': { const r = requireRoom(node, a.key); await r.addDevice(node.cred); await r.update(); return { writer: r.writer } }
    case 'room-delete': { const r = requireRoom(node, a.key); await r.deleteMessage(a.name); await r.update(); return { ok: true } }
    case 'room-flag': { const r = requireRoom(node, a.key); await r.flag(a.target, a.reason); await r.update(); return { ok: true } }
    // ⛔ v1 verbs, gone with authority-by-signature. Said, not silently absent.
    case 'room-add': case 'room-request': case 'room-admit':
      throw new Error(cmd + ' is gone: rooms admit by invitation now (ROOM-CONTRACT v2) — use room-invite, then room-join-invite on the joining node')
    // ⛔ A community is a ROOM WHOSE LISTINGS ARE ROOMS — not a type.
    case 'room-list': { const r = requireRoom(node, a.key); await r.listRoom(a.room, a.note); await r.update(); return { listed: a.room } }
    case 'room-delist': { const r = requireRoom(node, a.key); await r.delistRoom(a.room); await r.update(); return { delisted: a.room } }
    // ⛔ A DM is a room with two members. SOCIAL-MODEL §4: a two-member room IS a direct message,
    // and there is deliberately no separate DM type — one primitive, used.
    case 'dm': {
      // ⛔ Founded with both people (Keet `internal.js:57-64`); the other person's node binds its own core
      // with room-add-device. No invitation needed: they are a member from the start.
      const { key } = await node.createRoom({ initialMembers: [{ root: String(a.member).toLowerCase(), role: 'peer' }] })
      const r = node.room(key)
      await r.setSetting('title', 'DM')
      await r.update()
      return { key, member: a.member }
    }

    // ── browser identities ──────────────────────────────────────────────────────────────────
    //
    // ⛔ These are reachable over the daemon's UDS, which is owner-gated by the filesystem. Minting
    // an invite from here is therefore already "a human at this machine", which is exactly the gate
    // a one-time invite is supposed to be.
    case 'invite': return node.browsers.invite(a.ttlMs !== undefined ? { ttlMs: a.ttlMs } : {})
    case 'browser-challenge': return node.browsers.challenge()
    case 'browser-redeem': return node.browsers.redeem(a)
    case 'browser-claim': return node.browsers.claim(a)
    case 'browser-unclaimed': return { unclaimed: await node.browsers.unclaimed() }

    // ── profile ─────────────────────────────────────────────────────────────────────────────
    // ⛔ The PROFILE lives in the feed, not in the local registry — a registry does not replicate,
    // so a profile kept only there is invisible to everyone who reads your feed.
    case 'profile': {
      await peer.update()
      return a.feed ? (await peer.profileOf(a.feed)) || { name: null, avatar: null } : peer.profile()
    }
    case 'profiles': {
      await peer.update()
      await node.names.update()
      const profiles = await peer.profiles()
      // ⛔ The HANDLE is attached here so a byline can prefer it over the self-label. Resolution
      // order is handle -> self-label -> key: one is claimed in a shared log, one is what a feed
      // says about itself, and the third is the only unforgeable thing on the row.
      //
      // ⛔ CONFIRMED claims only. A claim names a feed; it does not prove the feed agrees, and this
      // loop used to attach any claim that pointed at one — so claiming a name at someone else's
      // feed key renamed them in every byline on the page. That is the attack arriving through the
      // display layer rather than the log, and `confirmedHandles` is the one place it is refused.
      for (const [feed, handle] of Object.entries(await node.confirmedHandles())) {
        if (profiles[feed]) profiles[feed].handle = handle
        else profiles[feed] = { name: null, avatar: null, handle }
      }
      return profiles
    }
    case 'set-profile': { const r = await peer.setProfile(a); await peer.update(); return r }
    // Which networks this identity publishes to. ⚠ A preference, not a bridge — see peer.reach().
    // ── bridges ────────────────────────────────────────────────────────────────────────────────
    //
    // ⛔ THIS DAEMON LEARNS NOTHING ABOUT THE NETWORK. It asks a bridge for a NAME and hands back the
    // answer; what the name means is the bridge's business and the caller's. The same three verbs
    // serve any bridge, which is the point — one ceremony rather than one per network.
    //
    // ⚠ The RESULT IS NOT RECORDED HERE. `set-reach` is a separate act because designating where this
    // identity publishes is a separate decision from holding a name — a person may hold one and not
    // publish under it.
    case 'bridge-policy': {
      const { connectBridge } = await import('./bridge.js')
      const { rpc } = await connectBridge(node.swarm, a.bridge)
      return rpc.request('policy', {})
    }
    case 'bridge-claim': {
      const { connectBridge, buildClaim } = await import('./bridge.js')
      const { rpc } = await connectBridge(node.swarm, a.bridge)
      // ⛔ A FRESH CHALLENGE PER ATTEMPT. It is single-use at the bridge, so reusing one across a
      // retry fails as "unknown or expired challenge" — which reads like a hostile caller.
      const { nonce } = await rpc.request('challenge', {})
      const req = buildClaim({
        nonce,
        username: a.name,
        feedKey: node.key.toString('hex'),
        root: node.rootHex(),
        cred: node.cred,
        invite: a.invite || null
      })
      const r = await rpc.request('claim', req)
      // The bridge answers in HTTP's vocabulary on purpose — it is already the contract a peer reads.
      if (r.status !== 200 && r.status !== 202) throw new Error((r.body && r.body.error) || ('bridge refused: ' + r.status))
      // ⛔ Remember WHICH bridge. Releasing needs the key again and `name@host` does not contain it;
      // asking a person to paste it a second time, for the one irreversible act here, is how the
      // wrong bridge gets asked.
      const { rememberBridge, acceptObserver } = await import('./bridge.js')
      rememberBridge(node.storage, r.body && r.body.acct, a.bridge)
      // ⛔ AND THE OBSERVATION FEED THE BRIDGE VOUCHED FOR (hyper-social #82, I1) — kept only if the bridge we just
      // claimed with signed it, then held right away so outside interactions start arriving without a restart.
      const o = acceptObserver(node.storage, a.bridge, r.body)
      if (o && typeof node.holdObserver === 'function') node.holdObserver(o).catch(() => {})
      // ⛔ AND RECORD WHICH BRIDGE CARRIES THE NAME IN THE IDENTITY, not only on this device: reach replicates, so
      // every device of this identity can ask that bridge for its observation feed at boot (hyper-social #82, I1).
      if (r.body && r.body.acct && node.peer) {
        try {
          await node.peer.setReach({ fediverse: r.body.acct, fediverseBridge: String(a.bridge).replace(/^bridge:/, '').toLowerCase() })
        } catch { /* an unattested device cannot write reach; the claim itself still stands */ }
      }
      return { ...r.body, pending: r.status === 202 ? (r.body && r.body.pending) || true : false }
    }
    // ⛔ CARRIED, not named. A bridge that fronts an identity serves what that identity SIGNED — it
    // holds no key for it — so this asks for availability, not for authority.
    case 'bridge-front': {
      const { connectBridge, buildFront } = await import('./bridge.js')
      const { rpc } = await connectBridge(node.swarm, a.bridge)
      const { nonce } = await rpc.request('challenge', {})
      const r = await rpc.request('front', buildFront({ nonce, id: a.id, root: node.rootHex(), cred: node.cred }))
      if (r.status !== 200 && r.status !== 202) throw new Error((r.body && r.body.error) || ('bridge refused: ' + r.status))
      return r.body
    }

    case 'bridge-release': {
      const { connectBridge, buildRelease, bridgeFor, forgetBridge, forgetObserver } = await import('./bridge.js')
      // ⚠ The remembered key wins over one supplied by the caller: the node knows which bridge it
      // actually claimed on, and a release sent to the wrong bridge is a request to burn a name
      // somewhere it was never held.
      const key = bridgeFor(node.storage, a.acct) || a.bridge
      const { rpc } = await connectBridge(node.swarm, key)
      const { nonce } = await rpc.request('challenge', {})
      const r = await rpc.request('release', buildRelease({ nonce, username: a.name, root: node.rootHex(), cred: node.cred }))
      if (r.status !== 200) throw new Error((r.body && r.body.error) || ('bridge refused: ' + r.status))
      forgetBridge(node.storage, a.acct)
      // The bridge's observation feed goes with it: its founder is no longer vouched for, so it renders nothing.
      const gone = (await import('./bridge.js')).observers(node.storage).find((o) => o.acct === a.acct)
      forgetObserver(node.storage, a.acct)
      if (gone && node.peer && node.peer.observers) node.peer.observers.delete(gone.observationFeed)
      // The identity no longer names a bridge for this name.
      try {
        const cur = node.peer ? await node.peer.reach() : null
        if (cur && cur.fediverse === a.acct) await node.peer.setReach({ fediverseBridge: null })
      } catch {}
      return r.body
    }

    case 'bridge-which': {
      const { bridgeFor } = await import('./bridge.js')
      return { bridge: bridgeFor(node.storage, a.acct) }
    }

    case 'reach': return peer.reach()
    case 'set-reach': { const r = await peer.setReach(a); await peer.update(); return r }
    // Bytes cross the socket as base64 — it is a local UNIX socket carrying a small image, and a
    // second transport for one field would be more moving parts than the field is worth.
    // ── files ───────────────────────────────────────────────────────────────────────────────
    //
    // ⛔ PATHS cross this socket, never bytes. Both ends are on one machine — it is a unix socket —
    // and `registerFile` takes a path, so sending a file through here as base64 meant encoding it
    // to hand it to a process that wrote it straight back to disk. The bytes themselves move
    // between PEERS over hyper-overlay, chunked and content-addressed, and always did.
    case 'avatar-put': {
      const r = await node.avatars.put(a.path)
      // The oid goes into the PUBLISHED profile, so the picture travels with the feed.
      await peer.setProfile({ avatar: r.oid })
      await peer.update()
      return r
    }
    // ⛔ A SECOND PICTURE, stored by the SAME machinery. `avatars.put` types from magic bytes and
    // registers in the overlay — none of which is avatar-specific — so a banner needs a verb, not a
    // mechanism. It is a separate verb rather than `avatar-put {field}` because the two are
    // different acts and a mistyped field name would silently replace the wrong picture.
    case 'banner-put': {
      // ⛔ ITS OWN CAP. `avatars.put` defaults to the AVATAR's 512 KiB, which is a thumbnail policy —
      // a banner is a wide header and does not fit in it, so every banner upload threw and the
      // profile kept the avatar written moments earlier in the same submission.
      const { BANNER_MAX_BYTES } = await import('./avatars.js')
      const r = await node.avatars.put(a.path, { maxBytes: BANNER_MAX_BYTES, kind: 'banner' })
      await peer.setProfile({ banner: r.oid })
      await peer.update()
      return r
    }
    case 'avatar-get': {
      const r = await node.avatars.get(a.oid)
      return r || { found: false }
    }
    // A file embedded in a record: `![alt](favela://blob/<oid>)`. No cap — the overlay chunks.
    case 'file-put': return node.files.put(a.path)
    /**
     * ⛔ THE PYRAMID, stored and pointed at in one act (SOCIAL-MODEL §6.6.1).
     *
     * The caller has already encoded the levels — that is authoring-time work and happens where the
     * encoder is: `<canvas>` in the browser for images, system ffmpeg for video. This stores each
     * whole file in the overlay exactly as a single blob is stored, then publishes ONE pointer
     * naming them. Bridges then SELECT a level rather than re-encoding per delivery.
     *
     * ⚠ The BASE oid is the key and is what a record references, so `![alt](favela://blob/<oid>)`
     * is unchanged and every reader that knows nothing of levels still resolves the author's own
     * bytes. Levels are additive; nothing already written moves.
     */
    case 'file-put-levels': {
      const input = Array.isArray(a.levels) ? a.levels : []
      if (!input.length) throw new Error('file-put-levels: at least one level is required')
      const stored = []
      for (const l of input) {
        if (!l || !l.path) throw new Error('file-put-levels: every level needs a path')
        const r = await node.files.put(l.path)
        stored.push({ oid: r.oid, w: Number(l.w) || 0, h: Number(l.h) || 0, size: r.size, by: l.by || AUTHOR })
      }
      // Largest first — makePointer sorts — so the base is level 0 and is the author's own bytes.
      // The type comes from the BASE level's bytes — files.put already sniffed it, and every level
      // of one asset is the same kind of thing.
      const baseInfo = await node.files.get(stored[0].oid)
      const pointer = makePointer({ mimeType: (baseInfo && baseInfo.mimeType) || '', size: stored[0].size, levels: stored })
      const base = pointer.levels[0]
      await peer.putBlobPointer(base.oid, pointer)
      await peer.update()
      return { oid: base.oid, pointer }
    }
    /**
     * ⛔ DERIVE the missing rungs of a video's pyramid, on a node that HAS an encoder.
     *
     * §6.6.4: the pyramid is allowed to be PARTIAL — a handset posts one level and a well-resourced
     * peer fills in the rest later. That is why this exists as a verb rather than happening on
     * write: the node that stored the bytes is not necessarily the node that can encode.
     *
     * ⛔ EVERY LEVEL IT MAKES IS `derived:`, NEVER the author's. Deriving is not authoring — a peer
     * adding a rung has produced a derived artifact and says so, or an author's bytes and somebody
     * else's re-encode become indistinguishable. §8's rule cost two of wj62's posts.
     *
     * ⚠ Says plainly when it cannot: a node without ffmpeg is an ordinary node, not a broken one.
     */
    case 'blob-derive': {
      const info = await node.files.get(a.oid)
      if (!info) return { ok: false, reason: 'no such blob' }
      if (!/^video\//.test(info.mimeType || '')) return { ok: false, reason: 'only video is derived here — images are encoded at authoring' }
      if (!await ffmpegAvailable()) return { ok: false, reason: 'no ffmpeg on this node — a level can be derived on one that has it' }

      const dims = await probe(info.path)
      if (!dims || !dims.height) return { ok: false, reason: 'could not probe the video' }

      const existing = await peer.blobPointer(a.oid)
      const have = new Set(((existing && existing.levels) || []).map((l) => l.h))
      const want = planLevels({ mimeType: info.mimeType, width: dims.width, height: dims.height })
        .filter((l) => !have.has(l.edge))
      if (!want.length) return { ok: true, added: 0, reason: 'every level this ladder defines already exists' }

      const me = b4a.toString(peer.feed.key, 'hex').slice(0, 8)
      const levels = ((existing && existing.levels) || [])
      if (!levels.length) levels.push({ oid: a.oid, w: dims.width, h: dims.height, size: info.size, by: AUTHOR })

      let added = 0
      for (const l of want) {
        const enc = await encodeLevel(info.path, l)
        if (!enc) continue
        // ⛔ A RUNG BIGGER THAN THE SOURCE IS NOT A RUNG. A fixed bitrate applied to already-small
        // or highly-compressible video produces a "smaller" level that costs more bytes and buys
        // nothing — measured on a synthetic 720p clip whose 480p rung came out larger. The browser
        // path guards the same way for the same reason.
        if (enc.size >= info.size) { try { fs.unlinkSync(enc.path) } catch {}; continue }
        const stored = await node.files.put(enc.path)
        try { fs.unlinkSync(enc.path) } catch {}
        levels.push({ oid: stored.oid, w: 0, h: l.edge, size: stored.size, by: derivedBy(me) })
        added++
      }
      if (!added) return { ok: false, reason: 'ffmpeg produced no usable level' }

      const pointer = makePointer({ mimeType: info.mimeType, size: info.size, levels })
      await peer.putBlobPointer(a.oid, pointer)
      await peer.update()
      return { ok: true, added, pointer }
    }
    /** The pointer for a blob — null when it has only the one level it was written with. */
    case 'blob-pointer': return (a.feed ? peer.blobPointerOf(a.feed, a.oid) : peer.blobPointer(a.oid)) || null
    /**
     * The level nearest a caller's ceiling, for a blob of any feed this node holds.
     *
     * ⛔ THE BRIDGE SUPPLIES THE CEILING; the choice is still the bridge's. Doing the arithmetic
     * here rather than in each bridge keeps ONE ladder — the alternative is the same table written
     * three times, which is how this monorepo got six copies of a signing format.
     *
     * ⚠ A blob with no pointer answers with its own single level, because that is what it has
     * (record-vocabulary §3.5: today's avatar is the single-level special case). Absence of a
     * pointer is never an error.
     */
    case 'blob-level': {
      const pointer = a.feed ? await peer.blobPointerOf(a.feed, a.oid) : await peer.blobPointer(a.oid)
      if (!pointer) {
        const info = await node.files.get(a.oid)
        if (!info) return { level: null, reason: 'no such blob' }
        const fits = (info.size || 0) <= (a.maxBytes || Infinity)
        return fits
          ? { level: { oid: a.oid, w: 0, h: 0, size: info.size, by: AUTHOR }, reason: 'single level' }
          : { level: null, reason: 'the only level is ' + info.size + ' bytes against a ceiling of ' + a.maxBytes }
      }
      return selectLevel(pointer, { maxBytes: a.maxBytes || Infinity, maxEdge: a.maxEdge || Infinity })
    }
    case 'file-get': {
      const r = await node.files.get(a.oid)
      return r || { found: false }
    }

    case 'browser-auth': return node.browsers.authenticate(a)
    case 'browsers': return { browsers: await node.browsers.list() }
    case 'browser-revoke': return node.browsers.revoke(a.publicKey)
    case 'browser-status': return node.browsers.status(a.publicKey)

    // ── names ───────────────────────────────────────────────────────────────────────────────
    //
    // ⛔ Every claim gets a number appended, always. There is no bare `alice` — the first claimant
    // is `alice0`, the next `alice1`. So a name is never scarce, nothing can be squatted, there is
    // no race to win, and no handle is privileged over another by being older.
    case 'namespace': {
      await node.names.update()
      const founder = await node.names.founder()
      return {
        key: node.names.key.toString('hex'),
        claims: (await node.names.list()).length,
        // ⛔ Reported, because "I resolve handles here" and "I carry this log" are different facts
        // and are fixed by completely different things. A node with no weight is not broken — it is
        // a reader — but nobody can tell from a key alone.
        founded: !!founder,
        founder: founder ? founder.root : null,
        admins: await node.names.admins(),
        writers: await node.names.writers(),
        requests: await node.names.requests(),
        carrying: node.names.base.isIndexer,
        writable: node.names.base.writable
      }
    }
    // Resolve handles in a different namespace from now on. The one we leave stays on disk.
    // ⛔ EVERY namespace this node holds, so "you can come back to it" is a door rather than a
    // promise. `namespaceSettings` has always said the one you leave stays on disk; without this
    // there was no way to learn what it was, and a 64-hex key someone has to have written down is
    // not a way back.
    case 'namespaces': {
      const { loadNamespaces } = await import('./device.js')
      const cur = node.names ? node.names.key.toString('hex') : null
      return {
        current: cur,
        namespaces: loadNamespaces(node.storage).map((n) => ({ ...n, current: n.key === cur }))
      }
    }
    // ⛔ FORGETS THE INDEX ENTRY, NEVER THE LOG. The slot stays on disk with every claim in it —
    // this removes it from the list, and joining by key again finds the same slot through the index
    // it was just dropped from... which it cannot. Stated plainly rather than discovered: forgetting
    // the CURRENT namespace is refused, and forgetting a founded one loses the only record of its
    // legacy slot, so that is refused too.
    case 'namespace-forget': {
      const { loadNamespaces, saveNamespaces } = await import('./device.js')
      const key = String(a.key || '').toLowerCase()
      const cur = node.names ? node.names.key.toString('hex') : null
      if (key === cur) throw new Error('that is the namespace this node is in — join another first')
      const known = loadNamespaces(node.storage)
      const entry = known.find((n) => n.key === key)
      if (!entry) throw new Error('this node does not hold that namespace')
      if (entry.slot === 'names') throw new Error('that is the namespace this node founded — forgetting it would lose the only record of where its claims live')
      saveNamespaces(node.storage, known.filter((n) => n.key !== key))
      return { forgotten: key, note: 'the log stays on disk; join by key to return to it' }
    }
    case 'namespace-join': return node.joinNamespace(a.key)
    // Ask to carry this namespace's log. Grants nothing; an admin has to answer.
    case 'namespace-request': { await node.names.request(node.cred, a.token); await node.names.update(); return { asked: node.names.base.local.key.toString('hex'), token: a.token ? 'redeemed' : null } }
    // ⛔ Minted over the daemon's UDS, which the filesystem already gates to the owner — so this is
    // already "a human at this machine", which is the gate the token stands in for elsewhere.
    case 'namespace-invite': return node.names.invite(node.cred)
    case 'namespace-uninvite': { await node.names.withdrawInvite(a.hash, node.cred); await node.names.update(); return { withdrawn: a.hash } }
    case 'namespace-invites': { await node.names.update(); return { invites: await node.names.invites() } }
    // ⛔ Answer an ask. Refuses loudly when this node carries no weight, because a grant riding a
    // weightless block is capped to zero SILENTLY (see namespace.js).
    case 'namespace-admit': { await node.names.admitWriter(a.writer, node.cred); await node.names.update(); return { admitted: a.writer } }
    case 'namespace-grant': { await node.names.grantAdmin(a.root, node.cred); await node.names.update(); return { admin: a.root } }
    case 'namespace-revoke': { await node.names.revokeDevice(a.device, node.cred); await node.names.update(); return { revoked: a.device } }
    case 'claim-name': {
      await node.names.claim(a.name, node.key, node.cred)
      await node.names.update()
      return { handle: await node.names.handleFor(node.key) }
    }
    case 'release-name': { await node.names.release(a.handle, node.cred); await node.names.update(); return { released: a.handle } }
    case 'rename': {
      await node.names.rename(a.handle, a.name, node.key, node.cred)
      await node.names.update()
      return { handle: await node.names.handleFor(node.key) }
    }
    case 'handles': return { handles: await node.handles() }
    // ⛔ Goes through the NODE, not the namespace: resolving a handle means checking the claim's
    // anchor against the feed's own founder, and only the node holds feeds. `names.resolve` alone
    // reports who CLAIMED a name, never whether the claim is true.
    case 'resolve': return (await node.resolveHandle(a.handle)) || { found: false }
    case 'search-names': return { handles: await node.handles(a.query) }

    // ── device pairing ──────────────────────────────────────────────────────────────────────
    case 'pair-offer': return node.pairOffer(a)
    case 'pair-status': return node.pairStatus()
    case 'pair-cancel': return node.pairCancel()
    case 'pair-accept': return node.pairAccept(a.invite)
    // ⚠ Reported, never folded into a listing: a record we hold a pointer for and cannot read is
    // not an absent record, and the CLI must be able to say which.
    case 'unresolved': return peer.unresolved()

    // ⛔ WHICH TRIGGER IS ACTUALLY FIRING. Built because three fixes in a row shipped a mirror
    // trigger that never fired, and nothing on a running node could say so — each time the only
    // evidence was how LONG a record took to appear, which is inference, not measurement. If
    // `append` and `connection` stay at zero while `stale` climbs, the events are dead. That is now
    // a fact you can read.
    case 'mirror-stats': return node._mirror ? node._mirror.stats() : { mirror: 'not running' }
    default:
      throw new Error('unknown command: ' + cmd)
  }
}

const view = (r, mine = null) => ({
  author: r.author,
  collection: r.collection,
  name: r.name,
  createdAt: r.record && r.record.frontmatter && r.record.frontmatter.createdAt,
  body: r.record && r.record.body,
  hits: r.hits,
  // ⛔ What *I* have already put on this row. Without it a surface renders the same button whether
  // or not I reacted, and pressing it twice looks the same as pressing it once. Computed from ONE
  // listing of my own feed for the whole page, never a lookup per row.
  //
  // ⚠ MINE only. Everyone else's reactions need a scan across held feeds, which is per-target work
  // (`reactions`) and belongs on a single-post view, not on every row of a timeline.
  ...(mine === null
    ? {}
    : (() => {
        const values = mine.get(Peer.targetId({ feed: r.author, collection: r.collection, name: r.name }))
        return { reactions: values ? [...values] : [], liked: !!values && values.has(LIKE_VALUE) }
      })())
})

// Stamp a page of rows with my own reactions AND reposts. One listing of each, for the page —
// never a lookup per row.
const withLikes = async (peer, rows) => {
  const mine = await peer.myReactions()
  const reposted = await peer.myReposts()
  const me = Peer.nameFor(peer.feed.key)
  return rows.map((r) => {
    const v = view(r, mine)
    v.reposted = reposted.has(Peer.targetId({ feed: r.author, collection: r.collection, name: r.name }))
    // ⛔ Whether I WROTE it — what lets a page offer delete on my own posts and replies only.
    v.own = r.author === me
    return v
  })
}