OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / as2-project.js

247 lines · javascript ref:
// 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

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