OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge / lib / plan.js

221 lines · javascript ref:
// plan.js — what one favela record becomes on each network. Pure: no I/O, no clock, no throw.
//
// ── Why this is one function and not two ──────────────────────────────────────────────────────
//
// `favela-markdown/lib/reach.js` exists because the reach check "would otherwise be written three
// times — favela-web, hyper-proto-peer, ap-bridge — and the third one would eventually be written
// wrong". The same argument applies to the projection itself: if hpp decides what reaches bsky and
// ap-bridge decides what reaches the fediverse, two components read one record and each answers the
// audience question alone. Here it is answered once, and the answer fans out.
//
// ⛔ EACH NETWORK DERIVES FROM THE MARKDOWN. Not from the other projection.
//
// Measured 2026-09-07: today's fediverse path takes `record.text` — the STRIPPED atproto text — and
// ap-bridge's `textToHtml` splits it on blank lines and wraps each in <p>. So `# Heading` and a
// bulleted list arrive at Mastodon as paragraphs containing literal • characters, because the AP
// projection is derived from the bsky one and bsky's `text` is plain by protocol. Mastodon renders
// <h2>, <ul>, <a>, <strong>. Deriving each network from the canonical markdown is the difference
// between sending structure and sending the residue of somebody else's format.
//
// ⛔ EVERYTHING FAILS CLOSED. An unknown room type, a missing verb, a body that will not parse: the
// answer is {favela} and nothing leaves. `reach.js` §6.1 is the one place a defect here is a privacy
// bug rather than a rendering bug.

import { maxReach } from 'favela-markdown/lib/reach.js'
import { strip } from 'favela-markdown/lib/strip.js'
import { render, embeds } from 'favela-markdown/lib/render.js'

// app.bsky.feed.post caps `text` at 300 GRAPHEMES and 3000 bytes. Graphemes, not characters — an
// emoji is one grapheme and four bytes, and counting the wrong one rejects valid posts.
const MAX_GRAPHEMES = 300
const MAX_BYTES = 3000

const BLOB_RE = /!\[[^\]]*\]\(favela:\/\/blob\//

// app.bsky.embed.images, quoted from the lexicon rather than recalled:
//   "blob": { "accept": ["image/*"], "maxSize": 2000000 }
//   "The raw image file. May be up to 2 MB, formerly limited to 1 MB."
//
// ⛔ I FIRST WROTE 1,000,000 HERE FROM MEMORY, which is the FORMER limit. It would have withheld
// every picture between 1 and 2 MB — including the one that prompted this work, a 1,044,866-byte
// screenshot that fits with room to spare. favela-web's resize-plan.js had the right number in a
// comment the whole time. Read the lexicon, do not remember it.
export const MAX_IMAGES = 4
export const MAX_IMAGE_BYTES = 2000000

/**
 * Which described images may actually be sent. Pure.
 *
 * ⛔ WITHHELD, NEVER RESIZED. We do not re-encode anyone's picture: favela's own cap is 512 KiB for
 * an avatar but a POST attachment has no cap at all (the overlay chunks), so a photo can and will
 * exceed what bsky takes. Shrinking it would publish a degraded copy under the author's name with
 * nothing to say it had been altered — the same argument that makes plan.js withhold a long post
 * rather than cut it. The record stays whole on favela and the loss is named.
 */
/**
 * Is the post we would write already the one that is there?
 *
 * ⛔ Compared against the REPO, like the profile — never a local note of what we last sent. Blob refs
 * compare by CID: the bytes are content-addressed, so an unchanged picture yields an identical ref.
 */
export function samePost (desired, actual) {
  if (!desired || !actual) return false
  const cids = (r) => ((r.embed && r.embed.images) || []).map((i) => {
    const b = i && i.image
    return (b && b.ref && b.ref.$link) || (b && b.cid) || null
  })
  const norm = (r) => JSON.stringify([r.text || '', r.createdAt || '', cids(r), ((r.embed && r.embed.images) || []).map((i) => i.alt || '')])
  return norm(desired) === norm(actual)
}

export function selectImages (described) {
  const images = []
  const losses = []
  for (const d of described || []) {
    if (!d || !d.path) { losses.push('an image could not be read from the peer'); continue }
    if (!/^image\//.test(d.mimeType || '')) {
      losses.push('image omitted — bsky takes image/*, this is ' + (d.mimeType || 'untyped'))
      continue
    }
    if (typeof d.size === 'number' && d.size > MAX_IMAGE_BYTES) {
      losses.push('image omitted — ' + d.size + ' bytes against bsky\'s limit of ' + MAX_IMAGE_BYTES + ' (not resized: that would publish an altered copy)')
      continue
    }
    images.push(d)
  }
  return { images, losses }
}

const byteLength = (s) => (typeof Buffer !== 'undefined' && Buffer.byteLength)
  ? Buffer.byteLength(s, 'utf8')
  : new TextEncoder().encode(s).length

const WITHHELD = (reason) => ({ project: false, losses: [reason] })

/**
 * @param {object} row  a favela record: { name, body, createdAt }
 * @param {object} ctx  { roomType, verb } — the CALLER supplies the room's kind, because reach.js
 *                      has no storage and cannot inspect a room. Missing or unknown fails closed.
 * @returns {{ networks: string[], bsky: object, fediverse: object }}
 */
export function planRecord (row, ctx) {
  const roomType = (ctx && typeof ctx.roomType === 'string') ? ctx.roomType : null
  const verb = (ctx && typeof ctx.verb === 'string') ? ctx.verb : 'post'

  // ⛔ The lattice decides the audience before anything is rendered. Rendering first and filtering
  // after is how a projection gets built for a network it was never allowed to reach.
  // ⚠ maxReach returns a SET. Treating it as an array silently produced {favela} for every room —
  // fail-closed, so nothing leaked, and nothing projected either. A deny-by-default bug is quiet by
  // construction, which is exactly why it needs a test that asserts the PERMITTED case.
  let networks = ['favela']
  try {
    const r = maxReach({ roomType, verb })
    if (r && typeof r[Symbol.iterator] === 'function') networks = [...r]
  } catch {
    networks = ['favela']
  }
  if (!networks.includes('favela')) networks.push('favela')

  const body = (row && typeof row.body === 'string') ? row.body : ''
  const createdAt = (row && row.createdAt) || null

  return {
    networks,
    bsky: networks.includes('bsky') ? planBsky(body, createdAt) : WITHHELD('room does not reach bsky'),
    fediverse: networks.includes('fediverse') ? planFediverse(body, createdAt) : WITHHELD('room does not reach the fediverse')
  }
}

/**
 * bsky — plain text plus facets, capped.
 *
 * ⛔ WITHHELD, NEVER TRUNCATED. Cutting a post at 300 publishes something the author did not write,
 * under their name, with nothing to say it was cut. The canonical record stays whole on favela and
 * the loss is reported.
 */
function planBsky (body, createdAt) {
  const losses = []
  let text = ''
  let facets = []
  try {
    const s = strip(body)
    text = (s && typeof s.text === 'string') ? s.text : ''
    facets = (s && Array.isArray(s.facets)) ? s.facets : []
  } catch (err) {
    return WITHHELD('could not strip the record: ' + ((err && err.message) || String(err)))
  }

  // ⚠ Named from the SOURCE. strip() removes an image entirely, so by the time we hold `text` there
  // is no evidence a picture was ever in the post — which is why the oids are collected here, off
  // the same parse render uses, and never off a regex over the stripped text.
  //
  // ⛔ NAMED, NOT UPLOADED. Resolving an oid to a blob ref is I/O; this module is pure. The caller
  // hands back refs and builds the embed, exactly as the profile projection does.
  let images = []
  try {
    images = (embeds(body) || []).filter((e) => e && e.oid && e.kind === 'image').map((e) => ({ oid: e.oid, alt: e.alt || '' }))
  } catch { images = [] }
  if (!images.length && BLOB_RE.test(body)) {
    // The regex sees a blob embed the parser did not return as an image — a link to a file rather
    // than a picture. Reported so "no images" and "a file bsky has no form for" stay distinguishable.
    losses.push('attachment(s) not projected — the embed is not an image')
  }
  if (images.length > MAX_IMAGES) {
    losses.push('only the first ' + MAX_IMAGES + ' of ' + images.length + ' images projected — bsky takes ' + MAX_IMAGES)
    images = images.slice(0, MAX_IMAGES)
  }

  if (!text.trim()) return { project: false, losses: losses.concat(['nothing to post — no text survives the strip']) }

  const graphemes = [...text].length
  if (graphemes > MAX_GRAPHEMES || byteLength(text) > MAX_BYTES) {
    return {
      project: false,
      losses: losses.concat(['too long for bsky — ' + graphemes + ' graphemes against a limit of ' + MAX_GRAPHEMES])
    }
  }

  if (facets.length) {
    // ⚠ Ours are {byteStart, byteEnd, type, value} where value is a favela:// URI. atproto wants
    // {index, features:[{$type, did}]}, so projecting one means resolving a favela identity root to
    // a did:plc: — the credential link in identity.md §5.1, which does not exist yet.
    losses.push(facets.length + ' facet(s) sent as plain text — resolving favela:// to a did:plc: is unbuilt')
  }

  return {
    project: true,
    // ⚠ The AUTHOR's time, unchanged. Stamping projection time would make every backfilled record
    // claim to be new; the field is the author's statement about when they wrote it.
    record: { $type: 'app.bsky.feed.post', text, createdAt },
    // The caller uploads these and attaches the embed; an empty list means there was nothing to send.
    images,
    losses
  }
}

/**
 * fediverse — HTML, because AS2 `content` is HTML and Mastodon renders a useful subset of it.
 *
 * ⛔ Rendered from the MARKDOWN, which is the entire point of planning both here. Headings, lists,
 * emphasis and links survive; they cannot survive a trip through bsky's plain `text`.
 */
function planFediverse (body, createdAt) {
  const losses = []
  let content = ''
  try {
    content = String(render(body) || '')
  } catch (err) {
    return WITHHELD('could not render the record: ' + ((err && err.message) || String(err)))
  }
  if (!content.trim()) return { project: false, losses: ['nothing to post — the record renders empty'] }
  if (BLOB_RE.test(body)) {
    // ap-bridge HAS projectAttachments; wiring it needs the blob to be reachable from there.
    losses.push('attachment(s) not projected — the blob is not yet offered to ap-bridge')
  }
  // ⚠ No length limit. Mastodon instances impose their own and reject at delivery; withholding here
  // on a guess would silence posts a given instance would have accepted.
  return { project: true, content, createdAt, losses }
}

export default { planRecord }