// ⛔ THE FEDIVERSE DERIVES FROM THE MARKDOWN, not from the atproto projection of it.
//
// Measured on the live actor: `content` is built from `record.text`, so `# Heading` and a list reach
// Mastodon as paragraphs of literal • characters. AS2 content is HTML and Mastodon renders <h2> and
// <ul>. The canonical record is markdown and has all of it.
//
// ⛔ AND NO DID IS INVOLVED. Nothing in this path names an atproto identifier, a PDS or an MST —
// which is what lets this bridge front an identity that has no atproto credential (identity.md §4.1).
import '../lib/bare-globals.js'
import test from 'brittle'
import { FavelaSource } from '../lib/favela-source.js'
const src = (reach, rows, roomType = 'public') => {
const s = new FavelaSource({
sock: '/tmp/x.sock', host: 'h', actorId: 'https://h/ap/actor', followersId: 'https://h/ap/followers', roomType
})
s.peer = { reach: async () => reach, ownPosts: async () => ({ feed: 'ab'.repeat(32), rows }) }
return s
}
const row = (body) => ({ name: '1788822724600', body, createdAt: '2026-09-07T00:00:00Z' })
test('★★ a heading and a list survive as HTML', async (t) => {
const s = src({ networks: ['fediverse'] }, [row('# Heading\n\n* one\n* two')])
const { notes } = await s.notes()
t.is(notes.length, 1)
t.ok(/<h1|<h2/.test(notes[0].content), 'a real heading: ' + notes[0].content.slice(0, 50))
t.ok(/<ul|<li/.test(notes[0].content), 'a real list')
t.absent(notes[0].content.includes('•'), 'and no literal bullet characters')
})
test('⛔ source.content is what the AUTHOR typed', async (t) => {
// A receiver offering an edit box uses `source`. Handing back HTML round-trips their markdown.
const md = '# Heading\n\n* one'
const { notes } = await src({ networks: ['fediverse'] }, [row(md)]).notes()
t.is(notes[0].source.content, md)
})
test('⛔ consent gates it — off means nothing, and says why', async (t) => {
const { notes, reason } = await src({ networks: [] }, [row('hello')]).notes()
t.is(notes.length, 0)
t.ok(/not enabled/.test(reason), reason)
})
test('⛔ a room that may not reach the fediverse projects nothing', async (t) => {
// reach.js: a members room and a dm reach favela only. Consent cannot widen that.
for (const roomType of ['members', 'dm']) {
const { notes, reason } = await src({ networks: ['fediverse'] }, [row('secret')], roomType).notes()
t.is(notes.length, 0, roomType + ' stays local')
t.ok(/may not reach/.test(reason), roomType + ': ' + reason)
}
// ⚠ But a FOLLOWERS room does reach the fediverse — AP has addressing where atproto has none.
const { notes } = await src({ networks: ['fediverse'] }, [row('hi')], 'followers').notes()
t.is(notes.length, 1, 'a followers room projects here')
})
test('⛔ nothing in this path names atproto — INCLUDING what it imports', async (t) => {
// ⛔ The first version of this test read only THIS file's text and passed while the module
// imported `objectIdFor` from records.js, which imports atproto-did-peer. The whole point is that
// an identity with no atproto credential can be fronted; a path that cannot LOAD without the
// atproto stack has not achieved that, however clean its own source reads.
const fs = (await import('bare-fs')).default
const seen = new Set()
// ⚠ 'atproto' is NOT in this list, and that is deliberate. as2-project.js declares the JSON-LD
// term `atproto: https://atproto.com/ns#` for the optional `atproto:cid` dedup key — a field that
// travels WHEN a DID exists and is absent when one does not. A vocabulary term is not a
// dependency; what matters is that nothing here IMPORTS the stack, asserted separately below.
const forbidden = ['did:', 'app.bsky', 'pdsUrl', 'MST']
const walk = (rel) => {
if (seen.has(rel)) return
seen.add(rel)
const path = new URL('../' + rel, import.meta.url).pathname
let src
try { src = fs.readFileSync(path, 'utf8') } catch { return }
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*/g, '')
for (const f of forbidden) t.absent(code.includes(f), rel + ': no ' + f)
// Follow only LOCAL imports — a package name is a different question, asserted below.
for (const m of code.matchAll(/from '(\.[^']+)'/g)) {
walk(('lib/' + m[1].replace(/^\.\//, '')).replace(/lib\/\.\.\//, ''))
}
}
walk('lib/favela-source.js')
t.ok(seen.size >= 2, 'followed the graph, not just one file: ' + [...seen].join(', '))
// And no atproto PACKAGE reaches it either.
const src = fs.readFileSync(new URL('../lib/favela-source.js', import.meta.url).pathname, 'utf8')
for (const m of src.matchAll(/from '([^'.][^']*)'/g)) {
t.absent(/atproto/.test(m[1]), 'imports no atproto package: ' + m[1])
}
})
// ── pictures ──────────────────────────────────────────────────────────────────────────────────
//
// ⛔ A post with a picture that federates its TEXT ONLY is worse than one that does not federate:
// the reader sees a sentence referring to something that is not there. The blob bytes are already
// in the shared overlay — one physical copy, per OverlayBlobStore — so this is resolution, not
// transport.
const OID = 'a1'.repeat(16)
const withBlob = (reach, mime = 'image/webp') => {
const s = src(reach, [{ name: 'p1', createdAt: '2026-09-07T00:00:00Z',
body: 'look at this\n\n' }])
s.peer.call = async (cmd, args) => {
if (cmd !== 'file-get') throw new Error('unexpected ' + cmd)
return mime ? { path: '/tmp/x', mimeType: mime, size: 1234 } : { found: false }
}
return s
}
test('★★ a picture becomes an AS2 attachment', async (t) => {
const { notes } = await withBlob({ networks: ['fediverse'] }).notes()
t.is(notes.length, 1)
const att = notes[0].attachment || []
t.is(att.length, 1, 'one attachment')
t.is(att[0].type, 'Document')
t.is(att[0].mediaType, 'image/webp', 'the type comes from the bytes, not the filename')
t.is(att[0].url, 'https://h/ap/blobs/' + OID, 'served by this bridge, from the overlay')
// ⛔ Alt text is an accessibility obligation, not a nicety.
t.is(att[0].name, 'a cat on a chair')
})
test('⛔ a blob the peer cannot describe is OMITTED, not guessed', async (t) => {
// A wrong mediaType makes a receiver render nothing and say nothing. An absent attachment at
// least matches what the reader can see.
const { notes } = await withBlob({ networks: ['fediverse'] }, null).notes()
t.alike(notes[0].attachment || [], [], 'no attachment invented')
t.ok(notes[0].content.includes('look at this'), 'and the text still federates')
})
test('⚠ a post with no picture carries no attachment key', async (t) => {
const { notes } = await src({ networks: ['fediverse'] }, [row('plain')]).notes()
t.absent(notes[0].attachment && notes[0].attachment.length)
})
// ⛔ `image` IS THE BANNER and `icon` IS THE AVATAR — two AS2 fields, two different pictures.
// Nothing here emitted `image` because favela's profile record had no banner field until
// favela-peer 0.0.49, so every actor this bridge served advertised no header and Mastodon rendered
// its own placeholder (hyper-social #67 §7.6). buildActor has always passed `profile.image`
// through, so this was the only missing link.
test('getProfile projects a banner oid as AS2 image, independently of the avatar', async (t) => {
const mk = (frontmatter) => new FavelaSource({
peer: {
async profile () { return { frontmatter, body: 'about me' } },
async reach () { return { networks: ['fediverse'] } },
async ownPosts () { return { rows: [] } }
},
host: 'ap.7rnx.net',
base: 'https://ap.7rnx.net/ap/users/ashatron',
actorId: 'https://ap.7rnx.net/ap/users/ashatron/actor',
followersId: 'https://ap.7rnx.net/ap/users/ashatron/followers'
})
const both = await mk({ name: 'ash', avatar: 'a'.repeat(64), banner: 'b'.repeat(64) }).getProfile()
t.ok(both.icon.url.endsWith('a'.repeat(64)), 'the avatar is the icon')
t.ok(both.image.url.endsWith('b'.repeat(64)), '⛔ and the banner is the image, not a second icon')
t.is(both.image.type, 'Image')
// Both hang off THIS user's base, never the primary's flat path.
t.ok(both.image.url.startsWith('https://ap.7rnx.net/ap/users/ashatron/blobs/'), 'under their own root')
// Each is independent: a profile with only one must not invent the other.
const onlyAvatar = await mk({ avatar: 'a'.repeat(64) }).getProfile()
t.ok(onlyAvatar.icon, 'avatar alone still projects')
t.absent(onlyAvatar.image, 'and no banner is invented')
const onlyBanner = await mk({ banner: 'b'.repeat(64) }).getProfile()
t.ok(onlyBanner.image, 'banner alone still projects')
t.absent(onlyBanner.icon, 'and no avatar is invented')
})
// ⛔ AS2 `summary` is HTML; the bio is markdown.
test('the summary is rendered, not handed over raw', async (t) => {
const src = new FavelaSource({
peer: {
async profile () { return { frontmatter: { name: 'ash' }, body: 'the **dev** box' } },
async reach () { return { networks: ['fediverse'] } },
async ownPosts () { return { rows: [] } }
},
host: 'ap.7rnx.net',
base: 'https://ap.7rnx.net/ap/users/ashatron',
actorId: 'https://ap.7rnx.net/ap/users/ashatron/actor',
followersId: 'https://ap.7rnx.net/ap/users/ashatron/followers'
})
const p = await src.getProfile()
t.absent(p.summary.includes('**'), '⛔ or the asterisks show on every follower’s screen')
t.ok(/<strong>|<b>/.test(p.summary), 'markdown became HTML')
})
// ⛔ The source names each deleted post by the SAME object id it was delivered under — a Delete for
// a different id retracts nothing.
test('★★ listDeleted gives each deleted post\'s rkey and its object id', async (t) => {
const s = src({ networks: ['fediverse'] }, [])
s.peer.ownDeleted = async () => ({ post: ['01gone'], reply: [] })
const { deleted } = await s.listDeleted()
t.is(deleted.length, 1)
t.is(deleted[0].rkey, '01gone')
const note = await s.project({ name: '01gone', body: 'x', createdAt: '2026-09-07T00:00:00Z' })
t.is(deleted[0].id, note.id, 'the id its Note was delivered under')
})
test('a peer with no ownDeleted reports nothing deleted', async (t) => {
t.alike(await src({ networks: ['fediverse'] }, []).listDeleted(), { deleted: [] })
})