OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / favela-source.js

244 lines · javascript ref:
// Reading favela records directly, instead of the atproto projection of them.
//
// ⛔ WHY THIS EXISTS. `records.js` reads `app.bsky.feed.post` and builds AS2 from `record.text`, so
// the fediverse gets a projection OF A PROJECTION: a heading and a list arrive at Mastodon as
// paragraphs of literal • characters, because bsky's `text` is plain by protocol and has no
// structure left to carry. `favela-markdown/spec/design.md` says both networks derive from the
// canonical markdown, neither from the other. This is that path.
//
// ⛔ AND IT REMOVES THE DID. Reading favela records needs no atproto identifier, no PDS and no MST —
// only the peer's own socket. That is what lets this bridge front an identity that has no atproto
// credential at all, which identity.md §4.1 says must be possible and currently is not.
//
// ⚠ ADDITIVE. The existing path is untouched and still serves the live actor. A node configures one
// or the other; nothing already federated changes shape.
//
// ⚠ CO-LOCATED for now. This reads the peer over a unix socket, which means the bridge runs on the
// same box as the peer whose records it projects. Reading a REMOTE peer means replicating its feed
// over the swarm — the shape ap-bridge already uses for atproto — and is a later step, not a
// different design.

import { FavelaPeer } from 'favela-identity/lib/peer-client.js'
import { render, embeds } from 'favela-markdown/lib/render.js'
import { maxReach } from 'favela-markdown/lib/reach.js'
import { projectNote, objectIdFor, blobUrl, standaloneNote } from './as2-project.js'

// ⚠ OURS, not quoted. bsky's ceiling is in a lexicon and is fixed; Mastodon's is an instance
// setting and it re-encodes on receipt, so there is nothing authoritative to quote. Generous
// because the base level is almost always the right answer for the fediverse.
const AP_MAX_IMAGE_BYTES = 8000000

export class FavelaSource {
  /**
   * @param {object} opts
   * @param {string} opts.sock       favela-peer's socket
   * @param {string} opts.host       the public hostname; actor and object ids derive from it
   * @param {string} opts.actorId
   * @param {string} opts.followersId
   * @param {string} [opts.roomType] the kind of room this identity's own feed is. ⚠ 'public' is an
   *                                 ASSUMPTION and is stated as one: the reach lattice cannot infer
   *                                 it, and if it is wrong it is wrong toward a leak.
   */
  constructor ({ sock, peer = null, host, base = null, actorId, followersId, roomType = 'public' }) {
    // ⛔ AN INJECTED PEER, or a socket path. A REGISTERED peer is on another machine, so its source
    // is a feed this bridge HOLDS (lib/favela-feed.js) and there is no socket to open. Building the
    // peer here rather than taking one is what made this bridge co-located with the only identity it
    // could ever project — see feed-holder.js. Both satisfy the same three calls.
    if (!peer && !sock) throw new Error('FavelaSource: a peer or a socket path is required')
    this.peer = peer || new FavelaPeer(sock)
    this.host = host
    // ⛔ This user's root — everything they mint hangs off it. Derived from actorId when not given,
    // because an actor id already ends in `/actor` and the two must never disagree.
    this.base = base || (actorId ? String(actorId).replace(/\/actor$/, '') : null)
    this.actorId = actorId
    this.followersId = followersId
    this.roomType = roomType
  }

  /**
   * Every record of this identity's own feed that may reach the fediverse, as AS2 Notes.
   *
   * ⛔ TWO GATES, both of which must pass. `maxReach` says what a room MAY reach — a followers room
   * cannot reach bsky at all, and a members room or a dm reaches neither network. The identity's own
   * `reach` preference says what its owner ASKED for. Neither substitutes for the other, and a
   * failure to read either is OFF: unknown consent is not consent.
   */
  async notes () {
    const reach = await this.peer.reach()
    if (!reach.networks.includes('fediverse')) return { notes: [], reason: 'the fediverse is not enabled for this identity' }

    const allowed = maxReach({ roomType: this.roomType, verb: 'post' })
    const may = allowed && typeof allowed[Symbol.iterator] === 'function' && [...allowed].includes('fediverse')
    if (!may) return { notes: [], reason: 'this room may not reach the fediverse' }

    const { rows } = await this.peer.ownPosts()
    const notes = []
    for (const row of rows) {
      const note = await this.project(row)
      if (note) notes.push(note)
    }
    return { notes, reason: null }
  }

  /**
   * ⛔ THE CONTRACT, not a convenience. Everything above a record source is written as though the
   * two sources were interchangeable, and they were not: this class implemented `notes()` alone,
   * so `getProfile` was absent and `router.js` swallowed the failure —
   * `getProfile().catch(() => ({}))` — and fell back to the PROCESS-WIDE config. Live, that served
   * the incumbent's display name, summary and avatar on somebody else's actor, silently. See
   * test/record-source-contract.js, which both sources now have to pass.
   */

  /** This identity's profile, from its own feed. Never `{}` — see above for what that caused. */
  async getProfile () {
    let rec = null
    try { rec = this.peer.profile ? await this.peer.profile() : null } catch { rec = null }
    const fm = (rec && rec.frontmatter) || {}
    // ⛔ THE SHAPE IS AS2's, matching projectProfile — `name`, `summary`, `icon`, `image`. The first
    // draft returned {displayName, summary, avatar}, which buildActor reads none of: it takes
    // `profile.name`. So the profile was fetched, ignored, and the actor fell through to the
    // config — the same wrong-identity output as having no getProfile at all. A method that exists
    // and returns the wrong keys is indistinguishable from a missing one, which is why the contract
    // now pins the shape and not just the name.
    const out = {}
    if (fm.name) out.name = fm.name
    // ⛔ RENDERED, because AS2 `summary` is HTML and the bio is markdown. Handed over raw, a bio
    // reading `the **dev** box` reaches Mastodon with the asterisks showing — the same defect the
    // NOTE path was built to avoid ("a heading and a list arrive as paragraphs of literal •
    // characters"), surviving one field over because the profile path was written separately.
    const bio = rec && rec.body ? String(rec.body).trim() : ''
    if (bio) {
      let html = ''
      try { html = String(render(bio) || '').trim() } catch { html = '' }
      // A bio that will not render still says something; the plain text is better than nothing.
      out.summary = html || bio
    }
    // ⚠ An oid, not a URL — the bytes are already in the overlay and are not copied. This mints the
    // URL this bridge serves them at, exactly as an attachment does.
    if (fm.avatar) out.icon = { type: 'Image', url: blobUrl({ host: this.host, base: this.base, cid: fm.avatar }) }
    // ⛔ `image` IS THE BANNER, and it is a different field from `icon` — AS2 has carried both since
    // the start and Mastodon renders both. Nothing emitted one because nothing upstream stored one,
    // so every actor this bridge served advertised no header and the destination showed its
    // placeholder (hyper-social #67 §7.6). buildActor has always passed `profile.image` through.
    if (fm.banner) out.image = { type: 'Image', url: blobUrl({ host: this.host, base: this.base, cid: fm.banner }) }
    return out
  }

  /**
   * `{ notes, cursor }` — the shape every caller expects.
   *
   * ⚠ `cursor` is always present, null included. Its ABSENCE would read as "no more", which is a
   * different statement from "no more right now" and would silently truncate an outbox.
   */
  async listNotes ({ limit = 20, cursor = null } = {}) {
    const { notes } = await this.notes()
    const start = cursor ? Math.max(0, notes.findIndex((n) => n.id === cursor) + 1) : 0
    return { notes: notes.slice(start, start + limit), cursor: null }
  }

  /**
   * `{ deleted: [{ rkey, id }] }` — this identity's deleted posts, each named by the object id its Note
   * was delivered under. The publisher retracts the ones it delivered.
   *
   * ⛔ Not gated on consent: a retraction publishes nothing. A peer that cannot report tombstones (the
   * socket client) yields none — never a guess from what is merely absent.
   */
  async listDeleted () {
    if (typeof this.peer.ownDeleted !== 'function') return { deleted: [] }
    const gone = await this.peer.ownDeleted()
    const names = (gone && Array.isArray(gone.post)) ? gone.post : []
    return { deleted: names.map((rkey) => ({ rkey, id: objectIdFor({ host: this.host, base: this.base, rkey }) })) }
  }

  /** One note by the rkey its own id ends with, or null. Never a different note. */
  /**
   * ⛔ WRAPPED IN ITS OWN @context. A Note inside a Create inherits the activity's context; one
   * fetched at its own URI does not, and a JSON-LD document with no context expands to NOTHING —
   * every AS2 term in it is an unmapped string. So a server dereferencing this got a document that
   * said nothing rather than a post.
   *
   * `RecordSource.getNote` has always done this (records.js:139). This did not, and the contract
   * asked only for "a note with an id", so the difference passed. That is the third source-parity
   * defect of exactly this shape — after getProfile being absent, and returning the wrong keys.
   */
  async getNote (rkey) {
    if (!rkey) return null
    const { notes } = await this.notes()
    const note = notes.find((n) => String(n.id).split('/').pop() === String(rkey))
    return note ? standaloneNote(note) : null
  }

  /**
   * ⛔ EMPTY, AND DELIBERATELY SO. A favela interaction is written in its AUTHOR's feed, so this
   * identity's own feed holds the likes and boosts IT made, not the ones made ABOUT it — and an
   * outbox of "things I liked" is not what a consumer of this method wants. The honest answer is
   * none until the doorbell exists (hyper-social HOLDING-AND-TELLING §5), rather than a list that
   * means something other than its name.
   */
  async listInteractions () {
    return { interactions: [] }
  }

  /**
   * The pictures a record carries, as AS2 attachments.
   *
   * ⛔ THE TYPE COMES FROM THE BYTES. favela-peer sniffs it from the file's head rather than from a
   * filename, and a blob it cannot describe is OMITTED rather than guessed: a wrong mediaType makes
   * a receiver render nothing and say nothing, while an absent attachment at least matches what the
   * reader can see.
   *
   * ⚠ The bytes are NOT copied. They are already in the shared overlay — one physical copy, per
   * OverlayBlobStore — and this only mints the URL this bridge serves them at.
   */
  async attachmentsFor (body) {
    let refs = []
    try { refs = embeds(body) || [] } catch { return [] }
    const out = []
    for (const ref of refs) {
      if (!ref || !ref.oid || ref.kind !== 'image') continue
      // ⛔ SELECT, never downsample. Mastodon resizes on receipt and its ceiling is instance-
      // configurable, so this is OUR conservative bound rather than a quoted limit — but choosing a
      // level is still the bridge's job and doing it here keeps both projections on one ladder.
      let chosen = null
      try { chosen = await this.peer.call('blob-level', { oid: ref.oid, maxBytes: AP_MAX_IMAGE_BYTES }) } catch { chosen = null }
      const oid = (chosen && chosen.level && chosen.level.oid) || ref.oid
      let info = null
      try { info = await this.peer.call('file-get', { oid }) } catch { info = null }
      if (!info || !info.mimeType) continue
      const doc = { type: 'Document', mediaType: info.mimeType, url: blobUrl({ host: this.host, base: this.base, cid: oid }) }
      // ⛔ Alt text is an accessibility obligation, not a nicety — carried whenever the author wrote one.
      if (ref.alt) doc.name = ref.alt
      out.push(doc)
    }
    return out
  }

  /** One record → one Note, with its structure intact. */
  async project (row) {
    const body = (row && typeof row.body === 'string') ? row.body : ''
    if (!body.trim()) return null
    let html
    try { html = String(render(body) || '') } catch { return null }
    if (!html.trim()) return null

    // ⚠ The rkey is the favela record's own name. AP object URIs derive from the rkey alone
    // (`record-vocabulary.md` §2), which is why they survive a DID rotation — and why using the
    // favela name here, rather than an atproto TID, keeps that property while removing the DID.
    const rkey = row.name
    const attachments = await this.attachmentsFor(body)
    return projectNote({
      // `record.text` still travels as `source.content`: what the author actually typed. A receiver
      // that offers an edit box uses it, and handing back HTML there would round-trip their markdown
      // into soup.
      record: { text: body, createdAt: row.createdAt },
      contentHtml: html,
      actorId: this.actorId,
      objectId: objectIdFor({ host: this.host, base: this.base, rkey }),
      followersId: this.followersId,
      attachments
    })
  }
}

export default { FavelaSource }