OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / favela-feed.js

146 lines · javascript ref:
// favela-feed.js — read a favela feed the bridge HOLDS, over the swarm.
//
// ⛔ THE DEFECT THIS REPLACES, and it was mine. `FavelaSource` reads its peer over a UNIX SOCKET,
// so the bridge has to run on the same box as the peer it projects. That is incompatible with what
// a bridge IS: a service peers CONNECT TO. `PerUser` exists so one bridge fronts many people, and
// it could never actually do that — they would all have had to share the bridge's machine. One
// instance per person is a hostname per person, which is the thing #65 §7 set out to remove.
//
// A favela feed is a hypercore on a discovery topic. The bridge holds it read-only exactly as any
// follower does — the same way tabertron holds ashatron's — and reads the same records out of it.
//
// ★ NOTHING IN FavelaSource CHANGES. It needs exactly three things from its peer: `reach()`,
// `ownPosts()` and `call('file-get')`. This presents those three from a held feed, so the socket
// becomes one adapter of two rather than the only way in. That is the same shape favela-peer's own
// `rpc.js` already has, where `dispatch()` is the interface and the socket is an adapter over it.
//
// ⛔ READ-ONLY BY CONSTRUCTION. There is no put, no append, no setReach — not disabled, ABSENT. A
// bridge holds somebody else's feed; the entire keyless claim rests on it being unable to write
// there, and a bridge that could write would be authoring under their name.

import b4a from 'b4a'
// ⛔ ONE ladder, owned by the vocabulary that defines it (record-vocabulary §3.5).
import { selectLevel } from 'favela-markdown/lib/levels.js'

const REACH = 'reach'
const SELF = 'self'
const POST = 'post'
const PROFILE = 'profile'
const BLOB = 'blob'

export class FavelaFeed {
  /**
   * @param feed   a held favela Feed — read-only. `update()`, `get(collection, name)`, `list(c)`.
   * @param files  optional; describes a blob by oid for attachments. Without one, a record's
   *               pictures are omitted rather than guessed at.
   */
  constructor ({ feed, files = null }) {
    if (!feed) throw new Error('FavelaFeed: a held feed is required')
    this.feed = feed
    this.files = files
  }

  /** The feed key, hex — what `ownPosts()` reports and what the registration recorded. */
  get key () {
    const k = this.feed.key
    return typeof k === 'string' ? k : b4a.toString(k, 'hex')
  }

  /**
   * Which networks this identity has turned on, from its own feed.
   *
   * ⛔ UNKNOWN CONSENT IS NOT CONSENT. A feed whose reach record has not replicated yet returns an
   * empty list, so a projection that needs the fediverse is withheld. Reading absence as "carry on"
   * would publish somebody's records under a guess about what they agreed to.
   */
  async reach () {
    await this.feed.update()
    const rec = await this.feed.get(REACH, SELF)
    const fm = (rec && rec.frontmatter) || {}
    return {
      networks: Array.isArray(fm.networks) ? fm.networks : [],
      credential: fm.credential || null
    }
  }

  /**
   * This feed's posts, in the shape `posts-by` returns over the socket — so FavelaSource cannot
   * tell which adapter it was handed.
   */
  async ownPosts () {
    await this.feed.update()
    const rows = await this.feed.list(POST)
    return {
      feed: this.key,
      rows: rows.map((r) => ({
        name: r.name,
        collection: r.collection,
        body: (r.record && r.record.body) || '',
        createdAt: (r.record && r.record.frontmatter && r.record.frontmatter.createdAt) || null
      }))
    }
  }

  /**
   * The posts and replies this feed's owner DELETED — tombstones, read off the held feed.
   *
   * ⛔ POSITIVE evidence, the only kind a Delete may be sent on: a post missing from ownPosts() could
   * be deleted or not replicated yet. A held feed from a favela-peer without `tombstones()` reports
   * none rather than failing — it simply cannot say.
   */
  async ownDeleted () {
    await this.feed.update()
    if (typeof this.feed.tombstones !== 'function') return { post: [], reply: [] }
    return { post: await this.feed.tombstones(POST), reply: await this.feed.tombstones('reply') }
  }

  /**
   * This identity's profile record — `profile/self` on their own feed.
   *
   * ⛔ It travels WITH the feed, which is the point: a bridge holding somebody's feed knows who
   * they say they are without being told separately, and cannot be handed a name that is not theirs.
   */
  async profile () {
    await this.feed.update()
    return this.feed.get(PROFILE, SELF)
  }

  /**
   * The socket verbs this adapter supports — currently one.
   *
   * ⛔ An unknown command THROWS rather than returning null. A silent null here would look exactly
   * like "that blob does not exist", and a caller would render a post with its picture quietly
   * missing instead of learning that the bridge cannot do what it asked.
   */
  async call (cmd, args = {}) {
    if (cmd === 'file-get') {
      if (!this.files || typeof this.files.get !== 'function') return null
      return this.files.get(args && args.oid)
    }
    /**
     * ⛔ SELECT A LEVEL — the bridge's half of the mip pyramid (SOCIAL-MODEL §6.6.1). The pointer
     * travels IN the feed, which is the whole reason it is a record rather than a local index: this
     * adapter holds somebody else's feed and nothing else, and can still choose.
     *
     * ⚠ A blob with no pointer has one level, which is every blob written before the pyramid
     * existed. Absence is the single-level case, never an error.
     */
    if (cmd === 'blob-level') {
      const oid = args && args.oid
      const maxBytes = (args && args.maxBytes) || Infinity
      await this.feed.update()
      const rec = await this.feed.get(BLOB, oid)
      const fm = (rec && rec.frontmatter) || null
      if (fm && Array.isArray(fm.levels)) return selectLevel(fm, { maxBytes })
      const info = this.files && typeof this.files.get === 'function' ? await this.files.get(oid) : null
      if (!info) return { level: null, reason: 'no such blob' }
      return (info.size || 0) <= maxBytes
        ? { level: { oid, w: 0, h: 0, size: info.size }, reason: 'single level' }
        : { level: null, reason: 'the only level is ' + info.size + ' bytes against a ceiling of ' + maxBytes }
    }
    throw new Error('FavelaFeed: unsupported command "' + cmd + '" — this adapter is read-only')
  }
}

export default { FavelaFeed }