// ⛔ ONE REACH DECISION, ONE PROJECTION PER NETWORK.
//
// `favela-markdown/lib/reach.js` says why this component exists at all: "Written here once, this
// 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 applies one level up. If hpp projects to
// bsky and ap-bridge projects to the fediverse, two things read the same record and each decides
// independently what it may reach. This plans once and fans out.
//
// ⛔ AND EACH NETWORK DERIVES FROM THE MARKDOWN, never from another projection. Measured: today the
// AP path takes `record.text` — the STRIPPED atproto text — and ap-bridge's textToHtml wraps it in
// <p>. So a heading and a list reach Mastodon as literal bullet characters, because the fediverse
// projection is derived from the bsky one and bsky's text is plain by protocol. A projection of a
// projection loses what the second one never had.
import test from 'brittle'
import { planRecord, selectImages, MAX_IMAGE_BYTES } from '../lib/plan.js'
const at = '2026-09-07T23:12:04.600Z'
const row = (body, extra = {}) => ({ name: '1788822724600', body, createdAt: at, ...extra })
test('★★ a public post reaches bsky and the fediverse', async (t) => {
const p = planRecord(row('hello'), { roomType: 'public', verb: 'post' })
t.alike(p.networks.sort(), ['bsky', 'favela', 'fediverse'])
t.ok(p.bsky.project, 'bsky gets a post')
t.ok(p.fediverse.project, 'the fediverse gets one too')
})
test('⛔ a followers room reaches the fediverse ONLY — atproto has no addressing', async (t) => {
// reach.js: "atproto has NO addressing at all, so a projection it cannot restrict is a leak, and
// the only safe answer is to withhold."
const p = planRecord(row('hello'), { roomType: 'followers', verb: 'post' })
t.absent(p.networks.includes('bsky'))
t.ok(p.networks.includes('fediverse'))
t.absent(p.bsky.project, 'and the bsky plan refuses')
t.ok(p.bsky.losses.some((l) => /reach|address|withheld/i.test(l)), p.bsky.losses.join('; '))
})
test('⛔ a members room and a dm reach NOTHING but favela', async (t) => {
for (const roomType of ['members', 'dm']) {
const p = planRecord(row('secret'), { roomType, verb: 'post' })
t.alike(p.networks, ['favela'], roomType + ' stays local')
t.absent(p.bsky.project, roomType + ' does not project to bsky')
t.absent(p.fediverse.project, roomType + ' does not project to the fediverse')
}
})
test('⛔ an UNKNOWN room type fails closed', async (t) => {
// Deny by default. An unrecognised container must never widen an audience.
const p = planRecord(row('hello'), { roomType: 'wat', verb: 'post' })
t.alike(p.networks, ['favela'])
})
test('★★ the fediverse gets STRUCTURE, bsky gets plain text', async (t) => {
// The whole reason each network derives from the markdown separately.
const md = '# Heading\n\n* one\n* two'
const p = planRecord(row(md), { roomType: 'public', verb: 'post' })
t.is(p.bsky.record.text.indexOf('#'), -1, 'bsky text carries no markdown syntax')
t.ok(/<h1|<h2/.test(p.fediverse.content), 'the fediverse gets a real heading: ' + p.fediverse.content.slice(0, 60))
t.ok(/<ul|<li/.test(p.fediverse.content), 'and a real list')
})
test('⛔ too long for bsky is WITHHELD there and still sent to the fediverse', async (t) => {
// 300 graphemes is bsky's limit, not the fediverse's. One network's ceiling must not silence the
// other — which is exactly what a single shared projection would have done.
const p = planRecord(row('x'.repeat(400)), { roomType: 'public', verb: 'post' })
t.absent(p.bsky.project)
t.ok(p.bsky.losses.some((l) => /300/.test(l)))
t.ok(p.fediverse.project, 'the fediverse has no such limit')
})
test('⛔ losses are reported on a SUCCESSFUL projection too', async (t) => {
// A mention still cannot be projected — resolving favela:// to a did:plc: is unbuilt — and the
// post goes anyway with the loss named beside it.
const p = planRecord(row('hello [@alice](favela://' + 'ab'.repeat(32) + ')'), { roomType: 'public', verb: 'post' })
t.ok(p.bsky.project, 'the post still goes')
t.ok(p.bsky.losses.length > 0, 'and the loss is named: ' + p.bsky.losses.join('; '))
})
// ⛔ THIS USED TO BE A LOSS. "attachment(s) not projected — bsky needs an uploaded blob ref, which
// is unbuilt" was reported for every post carrying a picture, and the picture reached the fediverse
// and never bsky. The oids are named here; the caller uploads them and attaches the embed.
test('★ a post image is NAMED for projection rather than reported as a loss', async (t) => {
const oid = 'cd'.repeat(32)
const p = planRecord(row('look '), { roomType: 'public', verb: 'post' })
t.is(p.bsky.images.length, 1, 'the picture is offered for upload')
t.is(p.bsky.images[0].oid, oid)
t.is(p.bsky.images[0].alt, 'a cat', '⛔ alt text is an accessibility obligation, not a nicety')
t.absent(p.bsky.losses.some((l) => l.includes('unbuilt')), 'and is no longer reported as unbuilt')
})
test('at most four images, and the rest are named as dropped', async (t) => {
const imgs = Array.from({ length: 6 }, (_, i) => '.repeat(64) + ')').join(' ')
const p = planRecord(row('many ' + imgs), { roomType: 'public', verb: 'post' })
t.is(p.bsky.images.length, 4, 'bsky takes four')
t.ok(p.bsky.losses.some((l) => l.includes('only the first 4 of 6')), 'and says so')
})
test('⛔ selectImages WITHHOLDS an oversized picture rather than resizing it', async (t) => {
const { images, losses } = selectImages([
{ oid: 'a', path: '/tmp/a.png', mimeType: 'image/png', size: 500 },
{ oid: 'b', path: '/tmp/b.png', mimeType: 'image/png', size: MAX_IMAGE_BYTES + 1 },
{ oid: 'c', path: '/tmp/c.pdf', mimeType: 'application/pdf', size: 10 }
])
t.is(images.length, 1, 'only the one bsky will take')
t.ok(losses.some((l) => l.includes('against bsky')), 'the oversized one is named, not shrunk')
t.ok(losses.some((l) => l.includes('image/*')), 'and so is the non-image')
})
test('⛔ planRecord NEVER throws — it runs on the projection path', async (t) => {
for (const bad of [undefined, null, {}, { body: null }, { body: 123 }]) {
t.execution(() => planRecord(bad, { roomType: 'public', verb: 'post' }), JSON.stringify(bad))
}
t.execution(() => planRecord(row('hi'), null), 'and with no context at all')
})