// as2-project.js — project a canonical atproto record into an ActivityStreams 2 object.
//
// THIS IS THE M3 THESIS IN CODE. The record is the single source of truth (DESIGN.md §3); the
// atproto side derives MST/commit from its bytes, and this module derives AS2 from the SAME bytes.
// Neither is stored. One post, two projections, no copies.
//
// PURE — no I/O, no state. The at-uri -> AP-URI mapping for replies is INJECTED (`resolveObjectUri`)
// rather than looked up here, so the whole mapping stays offline-testable and the id-map remains
// the only component that holds that binding.
//
// ⛔ DO NOT route this through a lowest-common-denominator envelope (hyper-social's
// bridge-envelope). `content: string` cannot carry facets, langs, CWs, alt text or quote posts, so
// anything richer than plain text would be permanently capped. Projections map from the canonical
// record DIRECTLY; bridge-envelope belongs only at the realtime-bridge boundary where lossiness is
// inherent anyway.
export const AS2_CONTEXT = 'https://www.w3.org/ns/activitystreams'
export const PUBLIC_AUDIENCE = 'https://www.w3.org/ns/activitystreams#Public'
// Declaring the atproto term makes our dedup extension self-describing rather than a bare unknown
// property. Implementations that ignore extensions still ignore it; ones that resolve terms get a
// meaningful IRI instead of a dangling name.
export const ATPROTO_TERMS = { atproto: 'https://atproto.com/ns#' }
// --- text -> HTML ---------------------------------------------------------------------------
// AS2 `content` is HTML — Mastodon renders it as such. A raw record's `text` is plain, so it MUST
// be escaped or a post containing `<` becomes broken markup (or worse) on every receiving server.
// An object's public id. ⛔ Derived from the RKEY ALONE — `record-vocabulary.md` §2 — which is why
// these URIs survive a DID rotation, and why the favela path can mint them with no atproto
// identifier anywhere in sight.
// Where this bridge serves a blob. Network-neutral: an oid or a cid, the URL is ours either way.
/**
* ⛔ `base` IS THE USER'S ROOT, not the host's. These derived from the host alone, so EVERY user's
* avatar and EVERY user's object landed on the flat `/ap/blobs/…` and `/ap/objects/…` — which
* belong to the primary. Two live consequences:
*
* an avatar 404d, because the flat blob route asks the PRIMARY's PDS for a favela oid
* two users could mint the SAME object id — hyper-social #59 §6 flagged precisely this
*
* The second had not bitten only because the incumbent's rkeys are atproto TIDs and favela's are
* record names, so they missed each other by luck rather than by design.
*
* `base` defaults to the flat path, so the primary's already-federated ids are byte-identical.
*/
export const blobUrl = ({ host, cid, base = null }) => `${base || `https://${host}/ap`}/blobs/${cid}`
export const objectIdFor = ({ host, rkey, base = null }) => `${base || `https://${host}/ap`}/objects/${rkey}`
export function escapeHtml (s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
// Paragraph-per-blank-line, <br> for single newlines — the convention Mastodon itself emits, so
// our posts render consistently alongside native ones.
export function textToHtml (text) {
const paras = String(text || '').split(/\n{2,}/)
return paras
.map((p) => '<p>' + escapeHtml(p).replace(/\n/g, '<br />') + '</p>')
.join('')
}
// --- the projection -------------------------------------------------------------------------
/**
* @param record the canonical record bytes, decoded (app.bsky.feed.post shape)
* @param uri at://did/collection/rkey — the record's atproto identity
* @param cid the record's CID — ★ the cross-network dedup key (see below)
* @param actorId our AP actor URI
* @param objectId https://host/ap/objects/<id> — from the id-map
* @param followersId our followers collection URI (for cc)
* @param resolveObjectUri (atUri) => apUri | null, for reply parents we have already projected
*/
export function projectNote ({ record, uri, cid, actorId, objectId, followersId, resolveObjectUri = () => null, attachments = [], contentHtml = null }) {
if (!record || typeof record.text !== 'string') throw new Error('as2: record.text is required')
if (!objectId) throw new Error('as2: objectId is required')
if (!actorId) throw new Error('as2: actorId is required')
const note = {
id: objectId,
type: 'Note',
attributedTo: actorId,
// ⛔ Rendered HTML when the caller has it, `record.text` otherwise.
//
// AS2 `content` is HTML and Mastodon renders <h2>, <ul>, <a>, <strong>. Building it from
// `record.text` means building it from the PLAIN-TEXT projection — a heading and a list arrive
// as paragraphs of literal • characters, because bsky's text has no structure to carry. A
// caller holding the canonical markdown can render it properly and pass it here.
content: contentHtml || textToHtml(record.text),
// The original plaintext, unmangled. Receivers that support `source` can show/edit the real
// text instead of round-tripping our HTML back into something lossy.
source: { content: record.text, mediaType: 'text/plain' },
published: record.createdAt || new Date().toISOString(),
url: objectId,
to: [PUBLIC_AUDIENCE],
cc: followersId ? [followersId] : [],
// ★ THE DEDUP KEYS. Both projections of this post derive from the same canonical bytes, so the
// CID is a NETWORK-INDEPENDENT content identity: a viewer who follows us on bsky AND on the
// fediverse sees two objects carrying the same `atproto:cid` and can collapse them into one.
// Identity dedup (actor alsoKnownAs) and content dedup (this) are both required — neither
// alone lets a consumer merge the two feeds correctly.
// ⚠ Only works for content WE author, since only we control both projections. A third party
// dual-publishing through a bridge has no shared identifier — that is a property of the
// projection model, not a general fediverse solution.
'atproto:uri': uri || undefined,
'atproto:cid': cid || undefined
}
if (attachments.length) note.attachment = attachments
if (record.langs && record.langs.length) {
// Mastodon reads a single `language`; AS2's contentMap is the multi-language form. Emit both:
// the record may legitimately declare several, and dropping to one silently loses information.
note.contentMap = Object.fromEntries(record.langs.map((l) => [l, note.content]))
}
// Replies. We can only set inReplyTo for a parent we can name in AP terms — either one we
// projected ourselves (id-map) or a foreign AP object we ingested. A parent we cannot resolve
// is left UNSET rather than guessed: a wrong inReplyTo grafts the post onto the wrong thread on
// every receiving server, which is worse than an orphaned reply.
const parentUri = record.reply?.parent?.uri
if (parentUri) {
const resolved = resolveObjectUri(parentUri)
if (resolved) note.inReplyTo = resolved
}
for (const k of Object.keys(note)) if (note[k] === undefined) delete note[k]
return note
}
// The Create wrapper. Its addressing MUST match the Note's or receivers disagree about who the
// post was for.
export function wrapCreate ({ note, actorId, activityId }) {
return {
'@context': [AS2_CONTEXT, ATPROTO_TERMS],
id: activityId,
type: 'Create',
actor: actorId,
published: note.published,
to: note.to,
cc: note.cc,
object: note
}
}
// A standalone Note fetched by URI needs its own @context; the one inside a Create does not.
export function standaloneNote (note) {
return { '@context': [AS2_CONTEXT, ATPROTO_TERMS], ...note }
}
// ▢ NOT YET PROJECTED — each needs a decision, not just code:
// facets -> <a>/mention markup: needs the byte-offset walk, and mentions must resolve to AP
// actors or they render as dead links.
// quote -> AS2 has no agreed quote form (FEP-044f is recent); Mastodon 4.4 has quote posts.
// deletes -> Delete/Tombstone, which is where the id-map's permanence starts to matter.
// --- interactions ---------------------------------------------------------------------------
//
// ⛔ WHY THESE RETURN null SO OFTEN. AS2 `object` is a URI the RECEIVER resolves, and a Mastodon
// server can do nothing with `at://did:plc:…`. Today the only atproto records with an AP identity
// are the ones we published ourselves (`objectIdFor` mints `https://<host>/ap/objects/<rkey>` for
// our repo, and the inbox accepts only Follow/Undo so no fediverse post ever lands here). A like of
// somebody else's bsky post therefore has NOTHING truthful to point at.
//
// The alternative would be to synthesise a plausible bridge URI on a host we do not control. That
// publishes a permanent reference — remote servers cache and re-fetch for years — to an endpoint we
// can neither fix nor guarantee, which is exactly the reasoning blobs.js uses for refusing to
// publish the membrane's blob URLs. So: name it, or say nothing.
//
// Covering third-party subjects is not a bug fix, it is a decision to become a republisher of other
// people's content (Bridgy Fed's model). That belongs upstream of this file.
// A like/repost record's subject is a strongRef: { uri, cid }.
function subjectUri (record) {
const s = record && record.subject
const uri = s && typeof s.uri === 'string' ? s.uri : null
return uri || null
}
/**
* app.bsky.feed.like -> AS2 Like, or null when the subject cannot be named in AP terms.
*
* Deliberately minimal addressing: a Like is a signal to the object's author, delivered to their
* inbox — not a public republication. Adding Public here would put every like in timelines.
*/
export function projectLike ({ record, activityId, actorId, resolveObjectUri = () => null }) {
const at = subjectUri(record)
if (!at) return null
const object = resolveObjectUri(at)
if (!object) return null
return {
'@context': [AS2_CONTEXT, ATPROTO_TERMS],
id: activityId,
type: 'Like',
actor: actorId,
object
}
}
/**
* app.bsky.feed.repost -> AS2 Announce, or null when the subject cannot be named.
*
* ⛔ ADDRESSING IS THE WHOLE FEATURE. A boost that is not addressed to Public + followers is
* accepted, returns 2xx and appears in nobody's timeline — the same silent-success shape as an
* Update without `updated`. `published` carries the RECORD's time so a boost keeps its real
* position in a timeline rather than jumping to now on every reprojection.
*/
export function projectAnnounce ({ record, activityId, actorId, followersId, resolveObjectUri = () => null }) {
const at = subjectUri(record)
if (!at) return null
const object = resolveObjectUri(at)
if (!object) return null
return {
'@context': [AS2_CONTEXT, ATPROTO_TERMS],
id: activityId,
type: 'Announce',
actor: actorId,
object,
published: (record && record.createdAt) || undefined,
to: [PUBLIC_AUDIENCE],
cc: followersId ? [followersId] : []
}
}
/**
* Unlike / un-boost. Deleting the like or repost RECORD is modelled in AS2 as an Undo of the
* activity we sent — so the receiver needs that activity's id, which is why activity ids are
* derived from the rkey rather than a counter: the same record always yields the same id.
*/
export function projectUndo ({ activity, activityId, actorId }) {
if (!activity || !activity.id) return null
return {
'@context': [AS2_CONTEXT],
id: activityId,
type: 'Undo',
actor: actorId,
object: activity
}
}