OverviewFilesCommitsIssuesDocsReleasesPeers

favela-identity / lib / peer-client.js

93 lines · javascript ref:
// The client: how anything reaches its own favela-peer, over the local unix socket.
//
// ⛔ SHARED, because there are two consumers and the second would otherwise copy it. favela-bridge
// reads records to project onto bsky; ap-bridge reads the same records to project onto the
// fediverse. A wire protocol written twice drifts — this monorepo already carries six copies of one
// signing format, three of which eat the first character of a hash.
//
// ⚠ It lives beside attestation because both answer the same question from outside the daemon: who
// is this identity, and what did it write. Neither needs storage, a key, or a swarm.
//
// ⛔ READ ONLY, AND PULL. The peer is never asked to push and never told a bridge exists — that is
// the boundary its own test enforces (`favela-peer/test/boundary.test.js`, hyper-social #61 §10):
// "the moment the daemon imports the projection the dependency has been inverted". So this reaches
// in, takes records in favela's own vocabulary, and the peer stays ignorant of what happens next.
//
// It is also the shape every other bridge here already uses — ap-bridge's records.js: "replicate the
// peer's cores over Hyperswarm, read records, project. No key involved." This does the same over a
// socket instead of the swarm, because for a co-located bridge a DHT rendezvous buys nothing.
//
// ⚠ Its wire format is newline-delimited JSON, `{id, cmd, args}` → `{id, ok, value|error}`, NOT
// hyper-svc. The two daemons in this path speak different protocols and that is not worth papering
// over: favela-peer's is deliberately the smallest thing that works (its rpc.js says so).

import net from 'bare-net'

export class FavelaPeer {
  constructor (sock) {
    if (!sock) throw new Error('FavelaPeer: a socket path is required')
    this.sock = sock
  }

  /** One request, one connection. There is no session to keep and no benefit to holding one open. */
  call (cmd, args = {}) {
    return new Promise((resolve, reject) => {
      const c = net.connect(this.sock)
      let buf = ''
      let done = false
      const finish = (fn, v) => { if (!done) { done = true; try { c.destroy() } catch {} ; fn(v) } }
      c.on('error', (err) => finish(reject, err))
      c.on('connect', () => c.write(JSON.stringify({ id: 1, cmd, args }) + '\n'))
      c.on('data', (d) => {
        buf += d
        const i = buf.indexOf('\n')
        if (i < 0) return
        let r
        try { r = JSON.parse(buf.slice(0, i)) } catch (err) { return finish(reject, err) }
        if (r.ok) finish(resolve, r.value)
        else finish(reject, new Error(r.error || 'favela-peer refused ' + cmd))
      })
      // ⚠ A peer that accepts the connection and never answers would hang the bridge for ever.
      setTimeout(() => finish(reject, new Error('favela-peer timed out on ' + cmd)), 30000)
    })
  }

  async whoami () { return this.call('whoami') }

  /**
   * Which networks this identity has turned on, from the peer's own feed.
   *
   * ⛔ READ EVERY CYCLE, never cached. A person who unticks bsky in settings has withdrawn consent
   * to publish, and a bridge holding a stale copy would keep publishing until someone restarted it.
   *
   * ⛔ AND A FAILURE READS AS OFF. If the peer cannot be asked, the answer is not "carry on with
   * what we had" — it is publish nothing. Unknown consent is not consent.
   */
  async reach () {
    try {
      const r = await this.call('reach')
      return {
        networks: Array.isArray(r && r.networks) ? r.networks : [],
        credential: (r && r.credential) || null
      }
    } catch {
      return { networks: [], credential: null, unknown: true }
    }
  }

  /**
   * This node's own posts, newest last.
   *
   * ⚠ OUR OWN ONLY. A bridge projects what its identity wrote; forwarding a followed feed's posts
   * under our DID would be republishing someone else's words as ours, which is a different act with
   * a different name and is not this.
   */
  async ownPosts () {
    const me = await this.whoami()
    const rows = await this.call('posts-by', { feed: me.feed })
    return { feed: me.feed, rows: Array.isArray(rows) ? rows : [] }
  }
}

export default { FavelaPeer }