OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / inbound-notifications.js

75 lines · javascript ref:
// inbound-notifications.js — what the fediverse sent us, in the shape favela already uses.
//
// ⛔ WHY NOT "WRITE IT INTO THE PEER'S LOG", which is where this was heading.
//
// favela-peer has nowhere to put a record whose author has no feed. Every record lives in its
// author's feed; `notifications()` is a SCAN of the feeds this node holds (peer.js:640); and the
// doorbell — the answer for "somebody you hold NOTHING of" — is keyed by FEED KEY (peer.js:705),
// which a Mastodon actor does not have and cannot get. Writing an inbound Note into a feed would
// mean the bridge authoring records as though it were the remote person. That is impersonation with
// extra steps, and it is the thing SOCIAL-MODEL §8's "never re-encode a record you did not author"
// exists to forbid.
//
// ★ THE MODEL ALREADY ALLOWS THE HONEST VERSION. peer.js:623 — a notification is "a PROJECTION…
// a pure function from records to a view, derived on read, never stored, never replicated". A view
// deriving from two sources is still a projection. So the bridge offers a second source in the same
// shape, the view merges them, and favela-peer goes on not knowing what a fediverse is — which its
// own boundary test requires.
//
// ⚠ This is a projection, not storage. Nothing here is written anywhere; `InboundStore` remains the
// only durable copy, and it is already capped and already truncates bodies.

// AS2 activity type → favela notification kind (peer.js:646: reaction · repost · reply · mention ·
// follow · knock). A `Create` is a reply when it answers something and a mention otherwise, which is
// exactly the distinction `inReplyTo` was kept for (inbound-store.js:41).
const KIND = {
  Like: 'reaction',
  Announce: 'repost',
  Follow: 'follow'
}

/**
 * One stored inbound item → one notification row, or null if we cannot say what it was.
 *
 * ⛔ FAILS CLOSED. A remote server can send any activity type it likes; rendering an unrecognised
 * one as the nearest familiar kind tells the reader something that did not happen.
 */
export function toNotification (item) {
  if (!item || !item.type) return null

  const kind = item.type === 'Create'
    ? (item.inReplyTo ? 'reply' : 'mention')
    : KIND[item.type]
  if (!kind) return null

  return {
    kind,
    // ⛔ An actor URI, NOT a feed key — which is what a peer row carries here. `source` below is
    // what lets a view tell them apart instead of trying to resolve one as the other.
    author: item.actor || null,
    at: item.published || item.receivedAt || '',
    // What it acted on, always something of OURS: the post that was liked or boosted, or the post a
    // reply answers. A Follow acts on you rather than on a post, so it has no target.
    target: item.type === 'Create' ? (item.inReplyTo || null) : (item.type === 'Follow' ? null : item.objectId || null),
    value: null,
    name: item.objectId || item.id || null,
    // ⛔ VERBATIM. inbound-store.js keeps remote HTML and says it must "NEVER be rendered as trusted
    // markup". Sanitising here would move the guarantee somewhere the renderer cannot see it, and a
    // caller would reasonably believe the string was already safe. It is not. Escape it downstream.
    body: item.content || '',
    // Load-bearing, not a label: a federated row is not addressable the way a native one is.
    source: 'fediverse'
  }
}

/** Newest first, matching the peer's ordering so a merged list needs no second sort key. */
export function toNotifications (items, { limit = 100 } = {}) {
  return (items || [])
    .map(toNotification)
    .filter(Boolean)
    .sort((a, b) => (a.at === b.at ? 0 : (a.at < b.at ? 1 : -1)))
    .slice(0, limit)
}

export default { toNotification, toNotifications }