OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / inbound-store.js

259 lines · javascript ref:
// inbound-store.js — what the fediverse sent us.
//
// ⛔ THE GAP THIS CLOSES. The inbox verified every signature, logged "accepted", returned 202 — and
// then dropped everything that was not a Follow on the floor (`default:` in router.js). So a reply
// from a Mastodon user, a mention, a boost of our post, a like: all received, all acknowledged so
// the sender considered them delivered, none of them kept. We have been broadcasting into a room we
// could not hear.
//
// The atproto side already does the opposite: spacedust subscribes to
// `app.bsky.feed.post:reply.parent.uri` precisely so we "receive exactly the interactions that touch
// our own content — replies, quotes, likes, follows", and inbound.js verifies and retains them. This
// is that capability for the other network.
//
// ⚠ NOT AN APPVIEW. This keeps what arrived, addressed to us, so it can be READ. It does not index,
// thread, or render — those are favela-web's job and a later increment. Storing it is the
// precondition; the value shows up when something displays it.
//
// JSON file, like followers.js and state.js. The skill records the intended end state (HyperDB,
// DESIGN.md §3) — this deliberately matches the existing M1 shape rather than inventing a third
// storage pattern for the same lifetime of data.

import fs from 'bare-fs'
import path from 'bare-path'

// ⛔ BOUNDED BY CONSTRUCTION. An inbox is written by REMOTE servers: whatever we accept, someone
// else decides how much of it there is. An unbounded file here is a disk-exhaustion bug with a
// signature check in front of it, not a feature. Newest-first, oldest evicted.
export const DEFAULT_CAP = 500

// Keep the fields a notification needs and drop the rest. A remote Note carries attachments,
// emojis, tags and instance-specific extensions; storing the whole thing means storing whatever a
// remote server decides to send, forever.
// ActivityPub's magic audience. An activity addressed to it is public by definition; anything else —
// a followers-only post, a direct message — is not, whatever else it looks like.
//
// ⚠ THREE SPELLINGS IN THE WILD. The full URI is canonical, `as:Public` is the compacted JSON-LD form,
// and bare `Public` appears from older implementations. Matching only the first quietly treats a public
// post as private, which fails in the safe direction but hides half the fediverse from a public page.
const PUBLIC_URIS = new Set([
  'https://www.w3.org/ns/activitystreams#Public',
  'as:Public',
  'Public'
])

const audience = (x) => {
  if (!x) return []
  const v = [].concat(x.to || [], x.cc || [], x.bto || [], x.bcc || [])
  return v.filter((a) => typeof a === 'string')
}

/**
 * Was this addressed to the public?
 *
 * ⛔ THE ANSWER GATES A PUBLIC PAGE, so it fails closed: anything this cannot positively show to be
 * public is treated as private. Mastodon delivers DIRECT MESSAGES to an inbox exactly like it delivers
 * replies, and they arrive at the same handler — so an inbound store that could not tell them apart
 * would publish somebody's DM the moment anything rendered it without a login.
 */
export function isPublicActivity (activity) {
  const obj = activity && typeof activity.object === 'object' ? activity.object : null
  for (const a of audience(activity).concat(audience(obj))) {
    if (PUBLIC_URIS.has(a)) return true
  }
  return false
}

// ⛔ AN INTERACTION CARRIES NO AUDIENCE, AND ABSENCE IS NOT EVIDENCE OF PRIVACY.
//
// `isPublicActivity` looks for to/cc on the activity or its object. A Like has neither — its `object`
// is a bare URI string and Mastodon sends no addressing at all — so "no audience" read as "not
// public" and EVERY inbound like, boost and follow was withheld by construction. Measured on the live
// bridge 2026-09-15: one Like from @fleeky@prsm.space, stored `public: false`, invisible on a page
// built to show precisely that.
//
// An interaction's visibility is not its own property: it belongs to the thing it acted on, which
// here is one of our own posts, and those are public. That is also what Mastodon does — favourites
// and boosts are visible to anyone who can see the post.
//
// ⚠ THE CONTENT RULE IS UNTOUCHED. A Create or Update with no addressing is still withheld, because a
// DIRECT MESSAGE arrives at the same inbox through the same handler, and that is the one mistake this
// whole area exists to prevent. Only the types below change, and an Announce — which CAN carry
// addressing, unlike a Like — still has its stated audience honoured when it supplies one.
const INTERACTION_TYPES = new Set(['Like', 'Announce', 'Follow'])

export function isInteraction (activity) {
  return !!(activity && INTERACTION_TYPES.has(activity.type))
}

// Was this addressed at all? Distinguishes "said nothing about audience" (an interaction) from
// "said followers-only" (a boost that means it).
function hasAddressing (activity) {
  return audience(activity).length > 0 ||
    (activity && typeof activity.object === 'object' ? audience(activity.object).length > 0 : false)
}

/** Should this appear on a page with no login in front of it? */
export function isShowable (activity) {
  if (isInteraction(activity)) {
    // Stated privacy beats the default; silence means the protocol had nothing to say.
    return hasAddressing(activity) ? isPublicActivity(activity) : true
  }
  return isPublicActivity(activity)
}

/**
 * Should a STORED item appear? `public` was recorded at arrival and cannot be recomputed — the
 * addressing is gone — so this is the stored value, with one narrow exception.
 *
 * ⛔ A Like or a Follow NEVER carries an audience, so a stored `false` on one is not a privacy
 * decision: it is the old rule mistaking silence for refusal. Re-deriving those is reading the type,
 * not inventing addressing we lost.
 *
 * ⚠ An Announce is NOT in that exception. It can carry an audience, so a stored value for one might
 * be a real answer, and treating it as an artifact could publish a followers-only boost. There are
 * none on the live bridge; new ones get the correct rule at arrival.
 */
export function showableStored (item) {
  if (!item) return false
  if (item.type === 'Like' || item.type === 'Follow') return true
  return item.public === true
}

export function normalize (activity, receivedAt, recipient = null) {
  const obj = activity && typeof activity.object === 'object' ? activity.object : null
  const objectId = obj ? obj.id : (typeof activity?.object === 'string' ? activity.object : null)
  return {
    id: activity?.id || null,
    type: activity?.type || null,
    actor: typeof activity?.actor === 'string' ? activity.actor : activity?.actor?.id || null,
    objectId: objectId || null,
    // inReplyTo is what makes a Note a REPLY to one of our posts rather than an unrelated mention.
    inReplyTo: obj?.inReplyTo || null,
    // Content is HTML from a remote server. Kept verbatim and NEVER rendered as trusted markup —
    // whatever displays this must escape or sanitise it. Truncated: a notification does not need a
    // 40 KB post body, and the full object is always refetchable by id.
    content: typeof obj?.content === 'string' ? obj.content.slice(0, 4000) : null,
    url: obj?.url || objectId || null,
    // ⛔ THE PICTURES, OR A REPLY WITH ONE IS SHOWN AS EMPTY TEXT. Kept as {url, mediaType, name}
    // only — `name` is the alt text, and the rest of a remote attachment is whatever that server
    // decided to send. Bounded here as well as at render: this is the copy that goes to disk.
    attachment: Array.isArray(obj?.attachment)
      ? obj.attachment.filter((a) => a && typeof a.url === 'string')
        .slice(0, 8)
        .map((a) => ({ url: a.url, mediaType: a.mediaType || null, name: a.name || null }))
      : null,
    published: obj?.published || activity?.published || null,
    // ⛔ RECORDED AT ARRIVAL, because the addressing is not recoverable later — `to`/`cc` are dropped
    // by this very function, and the object is only refetchable while the remote server still serves
    // it. Anything stored before this field existed is `undefined`, which every reader must treat as
    // NOT public; see the note on isPublicActivity.
    public: isShowable(activity),
    // ⛔ WHOSE MAIL IS THIS. One InboundStore serves the whole process while the inbox PATHS are
    // per-user (`/ap/users/<name>/inbox`), so without this a kept item cannot say who it was
    // delivered to. That is harmless while only the operator reads the store and a leak the moment a
    // peer can ask for its own inbound — which is the only reason to keep it at all.
    //
    // ⚠ FORWARD-ONLY. `to`/`cc` are dropped above and the object is refetchable only while the
    // remote server still serves it, so anything stored before this field is permanently
    // unattributable and reads as `undefined`. A per-user read must exclude those rather than
    // guess — the same fail-closed posture `public` takes, and for the same reason.
    recipient: recipient || null,
    receivedAt
  }
}

export class InboundStore {
  constructor ({ file, cap = DEFAULT_CAP } = {}) {
    this.file = file
    this.cap = cap
    this._items = null
  }

  _load () {
    if (this._items) return this._items
    try {
      const raw = JSON.parse(fs.readFileSync(this.file, 'utf8'))
      this._items = Array.isArray(raw) ? raw : []
    } catch {
      this._items = []
    }
    return this._items
  }

  _save () {
    try {
      fs.mkdirSync(path.dirname(this.file), { recursive: true })
      fs.writeFileSync(this.file, JSON.stringify(this._items, null, 2) + '\n')
    } catch { /* a failed write must not fail the inbox: the 202 is already owed */ }
  }

  async add (item) {
    const items = this._load()
    // Remote servers retry, and a retry is not a second event. Dedup on activity id.
    if (item.id && items.some((i) => i.id === item.id)) return false
    items.unshift(item)
    if (items.length > this.cap) items.length = this.cap
    this._save()
    return true
  }

  // Delete{Note} — the object is gone on the origin, so it must go here too. Returns how many were
  // removed, because "nothing to delete" and "deleted" are different outcomes worth logging apart.
  async removeObject (objectId) {
    if (!objectId) return 0
    const items = this._load()
    const before = items.length
    this._items = items.filter((i) => i.objectId !== objectId)
    if (this._items.length !== before) this._save()
    return before - this._items.length
  }

  /**
   * A page of what arrived, newest first.
   *
   * ⛔ OFFSET, NOT A CURSOR, and deliberately. The store is newest-first and BOUNDED — eviction happens
   * at the head, so an offset can only ever drift by however many arrived while an operator was
   * reading, which shows up as a repeated row rather than a lost one. A cursor would be the right
   * answer for a log that grows without limit; this one is capped by construction.
   */
  async list ({ limit = 50, offset = 0, recipient = null } = {}) {
    const from = Math.max(0, Number(offset) || 0)
    // ⛔ FILTERED BEFORE PAGING. Paging first and filtering second yields short pages and an offset
    // that no longer means what the caller asked for — the same mistake the public timeline had to
    // fix, recorded at its call site.
    const all = recipient ? this._load().filter((i) => i.recipient === recipient) : this._load()
    return all.slice(from, from + limit)
  }

  /** How many are held, so a page can say whether there is more without fetching it. */
  async count () { return this._load().length }
}

// The test double, matching MemoryFollowerStore / MemoryState.
export class MemoryInboundStore {
  constructor (items = [], cap = DEFAULT_CAP) { this.items = items; this.cap = cap }
  async add (item) {
    if (item.id && this.items.some((i) => i.id === item.id)) return false
    this.items.unshift(item)
    if (this.items.length > this.cap) this.items.length = this.cap
    return true
  }

  async removeObject (objectId) {
    const before = this.items.length
    this.items = this.items.filter((i) => i.objectId !== objectId)
    return before - this.items.length
  }

  // ⚠ The SAME filter as the real store. A double that ignores an option makes every test using it
  // a statement about the double rather than about production.
  async list ({ limit = 50, offset = 0, recipient = null } = {}) {
    const from = Math.max(0, Number(offset) || 0)
    const all = recipient ? this.items.filter((i) => i.recipient === recipient) : this.items
    return all.slice(from, from + limit)
  }

  async count () { return this.items.length }
}