// 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 }