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