OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host / lib / project.js

272 lines · javascript ref:
// Running the bsky projection ON THE DEVICE that holds the key.
//
// ⛔ WHY THIS HAS TO EXIST AT ALL. The two networks are not symmetric, and the asymmetry is the whole
// bug. ap-bridge HOLDS a registered name's feed and projects the fediverse from it server-side, so a
// phone federates without anything running on it. atproto cannot work that way: the membrane is
// KEYLESS and re-serves only what the device SIGNED, so an atproto record exists solely because
// something next to the signing key authored it. On a handset that key is on the handset.
//
// Measured, which is why this is not a theory: a phone with a correct PLC record, a handle resolving
// to it, an active relay and nine fediverse posts had an atproto repo containing one bootstrap record
// — `app.bsky.feed.post/self.hello`, written by hpp's fresh-account path — and no
// `app.bsky.actor.profile` at all. The appview had never indexed it (`indexedAt: null`). Nothing was
// failing. Nothing was ever running.
//
// ⛔ NO SOCKET, AND THAT IS THE POINT. favela-bridge reaches both halves over unix sockets; SELinux
// refuses an unprivileged Android app a `sock_file`, so neither exists here. Both halves are already
// in this process, so they are passed as objects — the same move `inProcessClient` makes for the
// surface, and the reason lib/project.js in favela-bridge takes `peer` and `bsky` rather than paths.

import fs from 'bare-fs'
import { dispatch } from 'favela-peer/lib/rpc.js'
import { projectCycle } from 'favela-bridge/lib/project.js'
import { BskySink, POST_COLLECTION } from 'favela-bridge/lib/bsky.js'
import { tidFor } from 'favela-bridge/lib/tid.js'

/**
 * hpp's UDS control surface, as a function call.
 *
 * ⛔ A FAITHFUL MIRROR OF `serve-device-uds.js`, not a reinterpretation. Each command below is the
 * same one-line handler that file registers, against the same device methods — because the desktop
 * projector talks to those handlers and a second implementation that drifts would project differently
 * on a phone than on a laptop, silently.
 *
 * ⛔ NO `close`. BskySink closes its client after every call, which is correct for a socket it opened
 * and fatal for the device this app is running on. bsky.js guards with `if (client && client.close)`,
 * so omitting it is this adapter saying: this one is not yours to close.
 */
export function deviceClient (hppNode) {
  if (!hppNode || !hppNode.device) throw new Error('deviceClient needs a booted hpp node')
  const device = hppNode.device
  const blobs = hppNode.blobs || null

  return {
    call: async (cmd, args = {}) => {
      switch (cmd) {
        case 'get-state': return device.state()
        case 'apply-write': return device.applyWrite(args)
        case 'get-record': {
          const { collection, rkey } = args
          if (!collection || !rkey) throw new Error('get-record: collection and rkey required')
          // ⛔ ABSENT IS null, NOT AN ERROR. "Not written yet" is an ordinary state for a caller
          // deciding whether to write, and the projector distinguishes it from "could not ask".
          try { return (await device.repo.getRecord(collection, rkey)) || null } catch { return null }
        }
        case 'upload-blob': {
          if (!blobs) throw new Error('upload-blob: this node has no blob store')
          const { path, bytes, mimeType } = args
          if (!bytes && !path) throw new Error('upload-blob: bytes or path required')
          let buf
          if (path) {
            try { buf = fs.readFileSync(path) } catch { throw new Error('upload-blob: no file at ' + path) }
          } else {
            buf = bytes
          }
          if (!buf || !buf.length) throw new Error('upload-blob: that file is empty')
          const type = mimeType || 'application/octet-stream'
          const { cid, size } = await blobs.put(buf, type)
          return { cid: cid.toString(), mimeType: type, size }
        }
        default: throw new Error('the in-process device surface does not serve ' + cmd)
      }
    }
  }
}

/**
 * favela-peer, as the projector expects to find it.
 *
 * ⛔ `reach()` FAILS CLOSED, matching favela-identity's socket client exactly. If consent cannot be
 * read the answer is publish nothing — unknown consent is not consent — and a projector that inherited
 * a "carry on with what we had" would keep publishing after someone switched a network off.
 */
export function peerClient (node) {
  const call = (cmd, args = {}) => dispatch(node, cmd, args)
  return {
    call,
    reach: async () => {
      try {
        const r = await call('reach')
        return {
          networks: Array.isArray(r && r.networks) ? r.networks : [],
          credential: (r && r.credential) || null
        }
      } catch {
        return { networks: [], credential: null, unknown: true }
      }
    },
    ownPosts: async () => {
      const me = await call('whoami')
      const rows = await call('posts-by', { feed: me.feed })
      return { feed: me.feed, rows: Array.isArray(rows) ? rows : [] }
    },
    // My positively-deleted posts and replies — what the projection retracts from bsky.
    ownDeleted: async () => call('deleted-own')
  }
}

/**
 * Pick the hpp node to author into.
 *
 * ⚠ `reach.credential` is identity.md §5.1's DESIGNATED credential and wins when it names a booted
 * DID. With one DID booted that is the answer anyway; with several and nothing designated there is no
 * right guess, so this refuses rather than picking — publishing under the wrong identity is not an
 * error anyone can take back.
 */
export function pickNode (hpp, credential = null) {
  const nodes = (hpp && hpp.nodes) || []
  if (nodes.length === 0) return { node: null, error: 'this device has no booted atproto identity' }
  // ⛔ hpp returns `byDid` as a MAP (device-node.js:879). Indexing it like an object never matched, so a
  // designated credential was ignored and a two-DID node refused every pass (tabertron, 2026-10-13).
  const designated = credential && hpp.byDid
    ? (typeof hpp.byDid.get === 'function' ? hpp.byDid.get(credential) : hpp.byDid[credential])
    : null
  if (designated) return { node: designated, error: null }
  if (nodes.length === 1) return { node: nodes[0], error: null }
  return { node: null, error: 'this device holds several identities and none is designated — choose one in settings' }
}

/**
 * One projection pass, on the device.
 *
 * ⛔ SAME FUNCTION AS THE DESKTOP RUNS. Everything network-shaped — consent, the reach lattice, TID
 * rkeys, reconcile-don't-republish, refuse-when-the-repo-is-unreadable — lives in favela-bridge and is
 * exercised by its tests. This file supplies two objects and nothing else.
 *
 * ⚠ `send` defaults to FALSE. Projection is publication onto a network that does not forget.
 */
export async function projectBsky (node, hpp, { send = false, limit = Infinity, reproject = false, roomType = 'public', log = () => {} } = {}) {
  const peer = peerClient(node)
  const reach = await peer.reach()
  const { node: hppNode, error } = pickNode(hpp, reach.credential)
  if (error) return { refused: error, sent: 0, skipped: 0, withheld: 0, held: 0 }

  const bsky = new BskySink(null, { connect: async () => deviceClient(hppNode), log: (m, d) => log(m + (d ? ' ' + JSON.stringify(d) : '')) })

  /**
   * A favela target → the atproto subject that names it.
   *
   * ⛔ THE CID IS COMPUTED, NOT LOOKED UP. An atproto record's CID is a pure function of its bytes —
   * `cidForValue(value)` — so naming our own record needs no extra index and no round trip to the PDS.
   * Verified against the live repo: the computed CID for a published post matched the one the PDS
   * serves, byte for byte.
   *
   * ⛔ AND IT RETURNS null RATHER THAN GUESSING. A subject that is not in the repo yet is the ordinary
   * case when an edge is projected before the post it points at; planEdge reports that as a wait, and
   * the next pass finds it. Inventing a CID would publish a like pointing at nothing.
   */
  const resolveSubject = async (target) => {
    if (!target || target.collection !== 'post') return null
    const rkey = tidFor(target.name)
    let value = null
    try { value = await bsky.getRecord(POST_COLLECTION, rkey) } catch { value = null }
    if (!value || value.unknown) return null
    try {
      const { cidForValue } = await import('atproto-repo/lib/cid.js')
      const c = await cidForValue(value)
      return { uri: 'at://' + hppNode.did + '/' + POST_COLLECTION + '/' + rkey, cid: String(c.cid) }
    } catch (err) {
      // ⛔ SAID OUT LOUD. This catch hid a real defect once already: `atproto-repo` was not resolvable
      // from this package, every subject came back null, and NOTHING was projected — with the log
      // reporting each edge as "the subject is not in the atproto repo yet", which is the message for
      // an ordinary wait. A silent null here is indistinguishable from a subject that has not been
      // published, and that is precisely the pair a person cannot tell apart from the outside.
      log('subject could not be named — ' + ((err && err.message) || String(err)))
      return null
    }
  }

  return projectCycle({
    peer,
    bsky,
    roomType,
    send,
    limit,
    reproject,
    log,
    sinkLabel: 'this device (' + hppNode.did + ')',
    resolveSubject
    // ⚠ resolveRoot is deliberately absent for now: with no chain to walk, planEdge falls back to
    // parent-as-root, which is correct for a reply to a top-level post and is every reply we can
    // currently make (a reply to a reply needs the foreign-subject decision to be useful anyway).
  })
}

/**
 * Keep projecting for as long as the app runs — favela-bridge's `--watch`, inside the app.
 *
 * ⛔ WHY. Without this a desktop that ran favela-host instead of the four separate apps would silently
 * stop posting to bsky: projection here was on demand and dry by default, and favela-bridge's watch
 * daemon was the only thing that sent (hyper-social #77 §5, change 3).
 *
 * ⛔ EVENT-DRIVEN, NOT POLLED. The bridge polled every 15 s; this runs one pass at boot and one per
 * append to any WRITER core of the node's own feed — ap-bridge FeedHolder's trigger for held favela
 * feeds. ⛔ NOT autobee's 'update': in 2.12.1 it fires only on an interrupt (index.js:676), never when the
 * view moves, and the 0.1.1 watch that listened for it projected once at boot and never again
 * (tabertron, 2026-10-13). Writers are re-hooked on every fire, so a device admitted later is covered.
 * Coalesced: never two passes at once, and any number of changes during a pass become exactly one more.
 *
 * ⚠ IT SENDS, as the deployed `--watch` did. Consent is not decided here: projectCycle reads the
 * node's reach and withholds anything a network is not switched on for.
 *
 * `atproto` is boot()'s promise of the hpp handle; null (no identity) means nothing to project.
 * `project` is injectable for tests. Returns { watching, stop }.
 */
export async function watchProjection (node, atproto, { log = () => {}, project = projectBsky } = {}) {
  const hpp = await atproto
  if (!hpp || !hpp.nodes || hpp.nodes.length === 0) return { watching: false, stop () {} }

  let running = false
  let again = false
  let lastRefusal = null
  let stopped = false
  const pass = async () => {
    if (stopped) return
    if (running) { again = true; return }
    running = true
    try {
      do {
        again = false
        // ⛔ One pass NEVER ends the watch — a sink respawning on OTA, a peer restarting, are ordinary.
        try {
          const r = await project(node, hpp, { send: true, log })
          // ⛔ projectBsky refuses WITHOUT logging when it cannot pick an identity, and on a node with
          // bsky on that silence means nothing reaches bsky. Said once per distinct reason.
          if (r && r.refused && r.refused !== lastRefusal) log('projection refused: ' + r.refused)
          lastRefusal = r && r.refused ? r.refused : null
        } catch (err) { log('projection pass failed: ' + ((err && err.message) || err)) }
      } while (again && !stopped)
    } finally { running = false }
  }

  const base = node.peer && node.peer.feed && node.peer.feed.base
  const hooked = new Map() // writer core -> listener
  const rehook = () => {
    let writers
    try { writers = base && base.writers } catch { return }
    if (!writers) return
    try {
      for (const w of writers) {
        const core = w && w.core
        if (!core || typeof core.on !== 'function' || hooked.has(core)) continue
        const onAppend = () => { rehook(); pass() }
        core.on('append', onAppend)
        hooked.set(core, onAppend)
      }
    } catch { /* a writer set mid-change is not a failure; the next fire re-hooks */ }
  }
  rehook()
  pass()
  return {
    watching: true,
    hooked: () => hooked.size,
    stop () {
      stopped = true
      for (const [core, fn] of hooked) { try { core.off('append', fn) } catch {} }
      hooked.clear()
    }
  }
}

export default { deviceClient, peerClient, pickNode, projectBsky, watchProjection }