OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / router.js

758 lines · javascript ref:
// router.js — the whole request surface as ONE pure-ish async function.
//
// WHY SPLIT FROM THE TRANSPORT: every dependency that touches the network or disk is injected
// (resolvePublicKey, followers, deliver, now), so the entire routing surface — including inbox
// signature verification and Follow/Accept handling — is testable offline with no port bound and
// no remote server. The bare-http1 adapter in server.js is then thin enough to be obviously
// correct. This is the same posture as lib/actor.js and lib/http-signature.js.
//
// Returns { status, headers, body } — never throws for client-caused conditions.

import {
  buildActor, buildWebfinger, buildCollection, matchesActor,
  wantsActivityJson, actorId, keyId, ACTIVITY_JSON, JRD_JSON
} from './actor.js'
import { verifyRequest } from './http-signature.js'
import { normalize } from './inbound-store.js'
import { KIND as OBS } from './observations.js'
import { htmlToText } from './html-text.js'

const json = (status, doc, type = 'application/json') => ({
  status,
  headers: { 'content-type': type + '; charset=utf-8' },
  body: JSON.stringify(doc)
})

const text = (status, s) => ({ status, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: s })
const html = (status, s) => ({ status, headers: { 'content-type': 'text/html; charset=utf-8' }, body: s })

export async function handleRequest (req, ctx) {
  const { method, path, query = {}, headers = {}, body = null } = req

  if (path === '/.well-known/webfinger') return webfinger(query, ctx)
  if (path === '/') return root(headers, ctx)
  if (path === '/healthz') return text(200, 'ok')

  // ⛔ THE PUBLIC TIMELINE — PUBLIC ITEMS ONLY, AND IT FAILS CLOSED. This renders what the fediverse
  // sent this bridge, with no login, so the filter is the whole safety property: Mastodon delivers
  // DIRECT MESSAGES to an inbox exactly as it delivers replies, through the same handler and into the
  // same store. normalize() records `public` at arrival because the addressing is not recoverable
  // afterwards — `to`/`cc` are dropped there and the remote object is only refetchable while its
  // server still serves it.
  //
  // ⚠ ANYTHING STORED BEFORE THAT FIELD EXISTED IS `undefined` AND IS WITHHELD. An operator sees a
  // shorter public page than they expect after upgrading; the alternative is publishing somebody's
  // DM because a record predates the question being asked.
  // ⛔ THE FRONT PAGE IS THE RELAY VIEW — every peer this bridge serves, in one stream. A peer's
  // own page stays at /ap/users/<name>/timeline, scoped, which is what makes this one safe to combine.
  if (path === '/timeline') return feedPage(query, ctx, { combined: true })

  if (path === '/ap/register/challenge') {
    if (!ctx.registrations || !ctx.admission) return text(404, 'not found')
    if (method !== 'GET' && method !== 'POST') return text(405, 'GET or POST only')
    return json(200, ctx.admission.challenge())
  }

  if (path === '/ap/register') {
    if (!ctx.registrations) return text(404, 'not found')
    if (method === 'GET') return json(200, { policy: ctx.registrations.policy, proof: !!ctx.admission })
    /**
     * ⛔ RELEASE — the holder's half. The operator's half goes through the control socket, because
     * "any name on this bridge" is not a thing an HTTP caller should be able to assert.
     *
     * The holder proves it with the same identity root they registered under. Releasing BURNS the
     * name: the next claimant gets a new generation, so they cannot inherit an audience addressed
     * to the last one.
     */
    if (method === 'DELETE') {
      let del
      try { del = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) }
      const { admitRelease } = await import('./admit.js')
      const r = await admitRelease(ctx, del)
      return json(r.status, r.body)
    }

    /**
     * ⛔ PUSH A PROFILE REFRESH. A remote server caches an actor and re-fetches on its own schedule
     * — roughly daily — so a changed name, avatar or bio is invisible until then. An Update is the
     * only thing that corrects it promptly, and it only reaches FOLLOWERS.
     *
     * Idempotent by fingerprint: the publisher sends nothing when the actor has not actually
     * changed, so calling this repeatedly cannot spam anybody.
     */
    if (method === 'PATCH') {
      let pat
      try { pat = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) }
      if (!ctx.refreshUser) return json(404, { error: 'not found' })
      const r = await ctx.refreshUser(pat.username, pat.force === true)
      return json(r.ok ? 200 : 404, r)
    }
    if (method !== 'GET' && method !== 'POST') return text(405, 'GET, POST, PATCH or DELETE only')

    let req_
    try { req_ = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) }
    const { admitClaim } = await import('./admit.js')
    const r = await admitClaim(ctx, req_)
    return json(r.status, r.body)
  }

  /**
   * ⛔ RESOLVE WHOSE REQUEST THIS IS, then dispatch.
   *
   * A flat path — `/ap/actor`, `/ap/objects/<rkey>` — belongs to the PRIMARY, because that is the id
   * every already-federated object and every existing follower is addressed under. A second user
   * lives at `/ap/users/<name>/…`. An unknown name is a 404 and never a fallback: answering for one
   * person under another's name is the failure this whole layer exists to prevent, and it would
   * arrive silently.
   *
   * ⚠ `ctx.users` is absent on a node that has not been given a registry, in which case the config
   * IS the single user and nothing below can tell the difference.
   */
  const hit = ctx.users ? ctx.users.route(path) : null
  if (ctx.users && !hit) return text(404, 'not found')
  const rest = hit ? hit.rest : path.startsWith('/ap') ? path.slice('/ap'.length) : path
  const c = hit ? withUser(ctx, hit.user) : ctx

  if (rest === '/') return root(headers, c)
  if (rest === '/actor') return actor(headers, c)
  if (rest === '/inbox') return method === 'POST' ? inbox(req, c) : text(405, 'POST only')
  if (rest === '/outbox') return outbox(query, c)
  // The same page as the primary's /timeline, for a registered peer — `c` is already bound to them,
  // so their notes, their inbound, their name, by the same code path.
  if (rest === '/timeline') return feedPage(query, c)
  if (rest.startsWith('/objects/')) return object(rest, c)
  if (rest.startsWith('/blobs/')) return blob(rest, c)
  if (rest === '/followers') return followers(c)
  if (rest === '/following') return collection(`${base(c)}/following`, c)

  return text(404, 'not found')
}

/**
 * A context bound to one user.
 *
 * ⚠ The handlers all read `ctx.config.{host,username,displayName,…}`. Rather than rewrite every one
 * of them, the user's own fields are laid over the config — so a single-actor node passes its own
 * config through unchanged and a multi-user node gets the right person's, by the same path.
 */
function withUser (ctx, user) {
  if (!user) return ctx
  const ids = ctx.users.ids(user)
  return {
    ...ctx,
    user,
    ids,
    config: { ...ctx.config, ...user, host: ctx.config.host },
    // Each user's records and followers are their own; a node that has not split them yet keeps
    // sharing the single set, which is correct while there is only one user.
    records: (ctx.recordsFor && ctx.recordsFor(user)) || ctx.records,
    // ⛔ A held user's blobs are in the overlay, not on the primary's PDS. Falling through to the
    // process-wide fetcher made every registered avatar a 404.
    fetchBlob: (ctx.blobsFor && ctx.blobsFor(user)) || ctx.fetchBlob,
    // ⛔ `followers`, NOT `followerStore` — that is the name every handler reads (the /followers
    // collection, onFollow's add, onUndo's remove). Setting a key nothing reads meant a registered
    // user's collection served the PRIMARY's followers and an accepted Follow was written to the
    // primary's list. Live: /ap/users/ashatron/followers and /ap/followers returned the same single
    // follower while <dataDir>/users/ashatron/ did not exist. Same shape as the `shared.followerStore`
    // defect one version earlier — a per-user value resolved correctly, read under another name.
    followers: (ctx.followersFor && ctx.followersFor(user)) || ctx.followers,
    // Their publisher, not the primary's — a follow of one actor must not drain another's queue.
    onFollowed: ctx.drainFor ? () => ctx.drainFor(user) : ctx.onFollowed,
    // ⛔ AND THEIR KEY SIGNS THEIR ACTIVITIES. The Accept for an inbound Follow goes out through
    // `ctx.deliver`, which holds the PRIMARY's LocalSigner — so a registered user's Accept was
    // signed by tabertron against tabertron's key id. Every receiver either rejects that signature
    // or reads the Accept as coming from a different actor than the one that was followed, and the
    // follow stays pending for ever. This is `keyId({host})` again, one layer out.
    deliver: deliverAs(ctx, user)
  }
}

/**
 * `ctx.deliver` bound to one user's signer.
 *
 * ⚠ The PRIMARY keeps the deliverer exactly as it was. `signerFor(primary)` resolves to the shared
 * signer, which index.js deliberately leaves null because the process deliverer already holds it —
 * so this returns `ctx.deliver` untouched and nothing about the incumbent's deliveries changes.
 */
function deliverAs (ctx, user) {
  const signer = ctx.signerFor ? ctx.signerFor(user) : null
  if (!signer || typeof ctx.deliver !== 'function') return ctx.deliver
  // An explicit per-call signer still wins; this only supplies one where there was none.
  return (d) => ctx.deliver({ ...d, signer: (d && d.signer) || signer })
}

// ⛔ The user's own base, not the host's. `/ap` for the primary, `/ap/users/<name>` for everyone
// else — every collection id a handler mints has to agree with the actor it belongs to.
const base = (ctx) => (ctx.ids ? ctx.ids.base : `https://${ctx.config.host}/ap`)

// --- discovery ----------------------------------------------------------------------------

function webfinger (query, ctx) {
  const resource = query.resource
  if (!resource) return json(400, { error: 'resource parameter required' })

  /**
   * ⛔ WEBFINGER IS HOW AN ACTOR IS FOUND AT ALL. A client resolves acct:name@host through this
   * before it ever fetches an actor URL — so a registered user whose /ap/users/<name>/actor serves
   * 200 while this 404s does not exist as far as the fediverse is concerned. That was live:
   * ashatron's actor answered 200 and acct:ashatron@ap.7rnx.net answered "no such resource".
   *
   * ⚠ An unknown name stays 404 and never falls back to the primary. Answering for one person under
   * another's name is the failure the whole per-user layer exists to prevent, and it would arrive
   * silently — the client would simply follow the wrong actor.
   */
  const acct = String(resource).replace(/^acct:/i, '').split('@')[0].toLowerCase()
  const user = ctx.users ? ctx.users.get(acct) : null
  if (user && ctx.users) {
    const ids = ctx.users.ids(user)
    return json(200, buildWebfinger({
      host: ctx.config.host,
      username: user.username,
      actorId: ids.actor,
      // Their page, not the bridge root — the root is the primary's.
      profileUrl: ids.base.endsWith('/ap') ? `https://${ctx.config.host}/` : ids.base
    }), JRD_JSON)
  }

  // No registry, or a name it does not know: the single-actor answer, unchanged.
  if (!matchesActor(resource, ctx.config)) return json(404, { error: 'no such resource' })
  return json(200, buildWebfinger({
    host: ctx.config.host,
    username: ctx.config.username,
    profileUrl: `https://${ctx.config.host}/`
  }), JRD_JSON)
}

/**
 * ⛔ AN ACTOR MUST NEVER WEAR SOMEBODY ELSE'S IDENTITY.
 *
 * This read `ctx.config.displayName` and `ctx.config.summary` unconditionally — which is the
 * PRIMARY's, because the config describes the process. For a registered user that put the
 * incumbent's name, bio and avatar on their actor, live, and silently: FavelaSource had no
 * getProfile, the `.catch(() => ({}))` below swallowed it, and every field fell through.
 *
 * The config is the primary's profile and nobody else's. A registered user with no profile record
 * renders as ITSELF with fields missing — having no display name is honest; wearing another
 * person's is not, and it is the one a reader cannot detect.
 *
 * ⚠ The `.catch` stays, but it can no longer hide a missing method: the contract test
 * (test/record-source-contract.js) is what makes sure every source implements all four.
 */
async function actorDoc (ctx) {
  const profile = ctx.records ? await ctx.records.getProfile().catch(() => ({})) : {}
  const isPrimary = !ctx.user || !ctx.users || ctx.users.primary === ctx.user
  return buildActor({
    ids: ctx.ids || null,
    profile,
    host: ctx.config.host,
    username: (ctx.user && ctx.user.username) || ctx.config.username,
    displayName: isPrimary ? ctx.config.displayName : (ctx.user.displayName || null),
    summary: isPrimary ? ctx.config.summary : (ctx.user.summary || null),
    // ⛔ THEIR key, not the process's. A second actor advertising the primary's key material is two
    // actors with one keypair — anything it signed would be signed by somebody else.
    publicKeyPem: (ctx.keysFor && ctx.user && (ctx.keysFor(ctx.user) || {}).publicKeyPem) || ctx.publicKeyPem,
    // ⛔ alsoKnownAs is an identity CLAIM — "this actor is also that DID". It belongs to the
    // primary alone; asserting the incumbent's DID on a registered user's actor would be claiming
    // their account is somebody else's.
    alsoKnownAs: isPrimary ? (ctx.config.alsoKnownAs || []) : (ctx.user.alsoKnownAs || []),
    published: ctx.config.published || undefined
  })
}

/**
 * One user's actor document, from outside the request path.
 *
 * ⛔ Goes through `withUser` rather than rebuilding the context, so a publisher sends EXACTLY the
 * document the actor endpoint serves. Two code paths producing "the actor" is how they drift, and
 * an Update whose object disagrees with the actor is worse than none.
 */
export async function actorDocFor (ctx, user) {
  return actorDoc(user ? withUser(ctx, user) : ctx)
}

async function actor (headers, ctx) {
  return json(200, await actorDoc(ctx), ACTIVITY_JSON)
}

// Blobs are immutable and content-addressed, so the cache header is aggressive on purpose — this
// is fetched by every remote server that renders an avatar.
async function blob (path, ctx) {
  if (!ctx.fetchBlob) return text(404, 'not found')
  const cid = path.slice('/blobs/'.length)
  if (!cid || !/^[a-z0-9]+$/i.test(cid)) return text(404, 'not found')
  try {
    const b = await ctx.fetchBlob(cid)
    if (!b) return text(404, 'not found')
    return { status: 200, headers: { 'content-type': b.contentType, 'cache-control': 'public, max-age=31536000, immutable' }, body: b.body }
  } catch (err) {
    ctx.log?.({ msg: 'blob fetch failed', cid, error: err.message })
    return text(404, 'not found')
  }
}

// A browser hitting the root should get something human; a fetcher that content-negotiates for
// activity+json gets the actor, since some implementations probe the bare host first.
async function root (headers, ctx) {
  if (wantsActivityJson(headers.accept)) return json(200, await actorDoc(ctx), ACTIVITY_JSON)
  const host = ctx.config.host
  const username = (ctx.user && ctx.user.username) || ctx.config.username
  return {
    status: 200,
    headers: { 'content-type': 'text/html; charset=utf-8' },
    body: `<!doctype html><meta charset=utf-8><title>@${username}@${host}</title>` +
      `<h1>@${username}@${host}</h1><p>An ActivityPub actor served by ap-bridge.</p>` +
      `<p><a href="/ap/actor">actor document</a></p>`
  }
}

async function collection (id, ctx) {
  return json(200, buildCollection({ id }), ACTIVITY_JSON)
}

// ★ THE OUTBOX IS A PROJECTION, not stored AS2. Records are read keylessly from the peer and
// mapped on demand (DESIGN.md §3) — the same bytes the atproto side derives its MST from. When
// there is no record source configured the collection is empty rather than an error: a bridge
// that cannot reach its peer should look quiet, not broken.
/**
 * The public feed — this peer's own public posts and the public activity sent to them, in one stream.
 *
 * ⛔ THE FILTER LIVES IN buildFeed, NOT HERE. It withholds anything that cannot positively show it was
 * public (a DM arrives at this inbox exactly like a reply) and anything addressed to another peer (one
 * InboundStore serves the whole process while the inbox PATHS are per-user). Duplicating either check
 * at the route is how one of them eventually drifts.
 *
 * ⚠ MERGED, THEN PAGED. Paging each source separately and interleaving afterwards yields short pages
 * and an offset that no longer means what the link said — the mistake the old handler had to note.
 */
// ⛔ A HANG IS NOT AN EXCEPTION, which is what the try/catch below could not know. Measured in
// production 2026-09-15: a handset held over the swarm stopped answering, and because the combined
// page reads every peer it hung all of them — /healthz and the other two outboxes were answering in
// milliseconds throughout. A page that renders two peers and NAMES the third is strictly better than
// a page that renders nobody.
//
// ⚠ The deadline is per PEER, not per page: one slow source must not spend the budget of the peers
// after it in the loop.
const PEER_DEADLINE_MS = 4000

function withDeadline (promise, ms, onTimeout) {
  return new Promise((resolve) => {
    let done = false
    const timer = setTimeout(() => { if (!done) { done = true; resolve(onTimeout()) } }, ms)
    promise.then(
      (v) => { if (!done) { done = true; clearTimeout(timer); resolve(v) } },
      () => { if (!done) { done = true; clearTimeout(timer); resolve(onTimeout()) } }
    )
  })
}

// What one peer contributes to a feed. `c` is a ctx already bound to that peer, so its records,
// its interactions and its name all come from the same place the rest of the router gets them.
async function gatherFor (c) {
  let notes = []
  if (c.records && typeof c.records.listNotes === 'function') {
    // ⚠ A record source that is down must not blank the page. The inbound half is still true, and a
    // feed missing our own posts is visibly wrong in a way an error page is not.
    try { notes = (await c.records.listNotes({ limit: 200, cursor: null })).notes || [] } catch (err) {
      c.log?.({ msg: 'feed: own records unavailable — showing inbound only', error: err.message })
    }
  }
  let own = []
  let unprojectable = []
  let unaddressable = 0
  if (c.records && typeof c.records.listInteractions === 'function') {
    try {
      const r = await c.records.listInteractions({ limit: 100 })
      own = r.interactions || []
      unprojectable = r.unprojectable || []
      unaddressable = r.unaddressable || 0
    } catch (err) {
      c.log?.({ msg: 'feed: own interactions unavailable', error: err.message })
    }
  }
  const recipient = (c.user && c.user.username) || (c.config && c.config.username) || null
  return { notes, own, unprojectable, unaddressable, recipient, isPrimary: !c.user || !c.users || c.users.primary === c.user }
}

async function feedPage (query, ctx, { combined = false } = {}) {
  if (!ctx.inbound) return text(404, 'this bridge keeps no inbound timeline')
  const limit = 50
  const offset = Math.max(0, Number(query && query.offset) || 0)
  const host = ctx.config.host

  // ⛔ ONE READ OF THE STORE, FILTERED PER PEER INSIDE buildFeed. Each item carries the recipient it
  // was delivered to, so it lands on exactly one peer's rows and cannot be counted twice — which is
  // the property that lets a combined page exist at all without the isolation becoming decorative.
  const inboundItems = await ctx.inbound.list({ limit: 100000, offset: 0 })

  const { buildFeed } = await import('./feed.js')
  const peers = combined && ctx.users ? ctx.users.all() : [null]
  let flat = []
  let unaddressable = 0
  const silent = []
  for (const u of peers) {
    const c = u ? withUser(ctx, u) : ctx
    const who = (c.user && c.user.username) || (c.config && c.config.username) || 'this peer'
    const g = await withDeadline(gatherFor(c), PEER_DEADLINE_MS, () => {
      silent.push(who)
      ctx.log?.({ msg: 'feed: peer did not answer within ' + PEER_DEADLINE_MS + 'ms — rendering without it', peer: who })
      return { notes: [], own: [], unprojectable: [], unaddressable: 0, recipient: who, isPrimary: false }
    })
    unaddressable += g.unaddressable
    flat = flat.concat(buildFeed({
      self: { handle: g.recipient ? '@' + g.recipient + '@' + host : null, name: (c.user && c.user.displayName) || (!u && ctx.config.displayName) || null },
      notes: g.notes,
      inbound: inboundItems,
      own: g.own,
      unprojectable: g.unprojectable,
      recipient: g.recipient,
      host,
      isPrimary: g.isPrimary
    }))
  }

  // ⛔ THREADED, THEN PAGED. Paging the flat rows first would cut a post away from its own replies.
  const { threadFeed } = await import('./feed.js')
  const rows = threadFeed(flat)

  const { publicTimeline } = await import('./public-timeline.js')
  return html(200, publicTimeline({
    host,
    who: combined ? host : (peers[0] === null && ctx.user ? '@' + ctx.user.username + '@' + host : '@' + ((ctx.user && ctx.user.username) || ctx.config.username) + '@' + host),
    rows: rows.slice(offset, offset + limit),
    offset,
    limit,
    total: rows.length,
    unaddressable,
    silent
  }))
}

async function outbox (query, ctx) {
  const id = `${base(ctx)}/outbox`
  if (!ctx.records) return json(200, buildCollection({ id }), ACTIVITY_JSON)

  try {
    const limit = Math.min(50, Math.max(1, parseInt(query.limit, 10) || 20))
    const { notes } = await ctx.records.listNotes({ limit, cursor: query.cursor || null })
    return json(200, buildCollection({ id, totalItems: notes.length, items: notes }), ACTIVITY_JSON)
  } catch (err) {
    ctx.log?.({ msg: 'outbox: record read failed', error: err.message })
    return json(200, buildCollection({ id }), ACTIVITY_JSON)
  }
}

// Individual objects, so a remote server that sees an id in a Create can fetch and verify it.
async function object (path, ctx) {
  if (!ctx.records) return text(404, 'not found')
  const rkey = path.slice('/objects/'.length)
  if (!rkey || rkey.includes('/')) return text(404, 'not found')
  try {
    const note = await ctx.records.getNote(rkey)
    if (!note) return text(404, 'not found')
    return json(200, note, ACTIVITY_JSON)
  } catch (err) {
    ctx.log?.({ msg: 'object: record read failed', rkey, error: err.message })
    return text(404, 'not found')
  }
}

async function followers (ctx) {
  const list = await ctx.followers.list()
  return json(200, buildCollection({
    id: `${base(ctx)}/followers`,
    totalItems: list.length,
    items: list
  }), ACTIVITY_JSON)
}

// --- inbox --------------------------------------------------------------------------------

async function inbox (req, ctx) {
  const { headers, body } = req
  if (!body) return json(400, { error: 'empty body' })

  // Verify BEFORE parsing. An unsigned or badly-signed request should never reach the activity
  // handler, and 401 is what tells a remote server its key or clock is wrong.
  // ⛔ THE PATH THE SENDER SIGNED, not the primary's. HTTP Signatures cover `(request-target)`, so
  // a remote server POSTing to /ap/users/ashatron/inbox signs
  // `(request-target): post /ap/users/ashatron/inbox` — verifying that against a hardcoded
  // '/ap/inbox' builds a different signing string and fails every time. Live, every signed Follow
  // of a registered user was rejected 401: the actor resolved, the follow silently never happened.
  const verdict = await verifyRequest({
    method: 'POST',
    path: req.path || '/ap/inbox',
    headers,
    body,
    resolvePublicKey: ctx.resolvePublicKey,
    maxSkewSeconds: ctx.config.maxSkewSeconds
  })
  if (!verdict.ok) {
    ctx.log?.({ msg: 'inbox: rejected', reason: verdict.reason, keyId: verdict.keyId })
    return json(401, { error: verdict.reason })
  }

  let activity
  try {
    activity = JSON.parse(typeof body === 'string' ? body : body.toString())
  } catch {
    return json(400, { error: 'body is not JSON' })
  }

  const actorUri = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id
  if (!actorUri) return json(400, { error: 'activity has no actor' })

  // The signing key must belong to the actor that claims to have sent this. Without this check a
  // valid signature from ANY actor would authenticate an activity attributed to ANYONE.
  if (!keyBelongsToActor(verdict.keyId, actorUri)) {
    ctx.log?.({ msg: 'inbox: key/actor mismatch', keyId: verdict.keyId, actor: actorUri })
    return json(403, { error: 'signing key does not belong to the actor' })
  }

  ctx.log?.({ msg: 'inbox: accepted', type: activity.type, actor: actorUri })

  switch (activity.type) {
    case 'Follow': return onFollow(activity, actorUri, ctx)
    case 'Undo': return onUndo(activity, actorUri, ctx)
    // ⛔ THESE USED TO FALL THROUGH TO THE 202 BELOW AND BE DISCARDED. Every reply, mention, like
    // and boost the fediverse sent us was signature-verified, logged as accepted, acknowledged so
    // the sender considered it delivered — and dropped. Meanwhile the atproto side subscribes to
    // `app.bsky.feed.post:reply.parent.uri` for exactly this data. Same product, opposite behaviour
    // per network.
    case 'Create':
    case 'Update':
    case 'Like':
    case 'Announce':
      return onInbound(activity, actorUri, ctx)
    case 'Delete':
      return onDelete(activity, actorUri, ctx)
    default:
      // Ack unknown types. Returning an error makes remote servers retry forever.
      return { status: 202, headers: {}, body: '' }
  }
}

// keyId is conventionally `<actor-uri>#main-key`, but the fragment and even the path can vary by
// implementation. Compare the key's document URI (everything before '#') to the actor URI.
export function keyBelongsToActor (kid, actorUri) {
  if (!kid || !actorUri) return false
  const doc = kid.split('#')[0]
  if (doc === actorUri) return true
  // Some implementations serve the key from a sibling path on the same origin as the actor.
  try {
    return new URL(doc).origin === new URL(actorUri).origin && doc.startsWith(actorUri)
  } catch {
    return false
  }
}

async function onFollow (activity, actorUri, ctx) {
  if (!activity.id) return json(400, { error: 'Follow has no id' })

  const added = await ctx.followers.add(actorUri)
  observe(ctx, { kind: 'follow', actor: actorUri, subject: null, activity: activity.id, at: Date.now() })

  // ⛔ AND IT IS AN OBSERVATION. A Follow is dispatched HERE, never through onInbound, so noting it
  // there alone recorded likes and boosts and silently lost every follow — caught by the test that
  // asserts all three land, which is why that test names all three rather than one.
  if (ctx.observations) {
    const recipient = (ctx.user && ctx.user.username) || (ctx.config && ctx.config.username) || null
    if (recipient) {
      try {
        ctx.observations.note({ recipient, subject: null, actor: actorUri, kind: OBS.FOLLOW, at: Date.now(), id: activity.id })
      } catch (err) { ctx.log?.({ msg: 'inbox: follow not recorded', actor: actorUri, error: err.message }) }
    }
  }

  // ⛔ A NEW FOLLOWER IS A REASON TO DRAIN. Posts made while nobody was following are HELD rather
  // than recorded as delivered (publisher.js `_send`), which only means something if somebody
  // eventually goes and sends them. Without this the held posts sit until an unrelated future post
  // advances the feed — "delivered eventually, if you happen to post again", which is the same trap
  // primeWatermark's backfill kick already exists to avoid.
  if (added && ctx.onFollowed) {
    try { await ctx.onFollowed() } catch (err) { ctx.log?.({ msg: 'follow drain failed', error: err.message }) }
  }

  // Accept is delivered ASYNCHRONOUSLY. Mastodon expects a prompt 202 on the inbox POST; doing the
  // outbound delivery inline would hold its request open for the duration of ours and can trip its
  // own timeouts. Delivery failure is retried by the queue, not surfaced here.
  const accept = {
    '@context': 'https://www.w3.org/ns/activitystreams',
    id: `${base(ctx)}/activities/${ctx.newId()}`,
    type: 'Accept',
    // ⛔ THEIRS. `actorId(ctx.config)` is always the flat `/ap/actor`, so a registered user's Accept
    // claimed to come from the primary — and Mastodon matches an Accept to the pending Follow by
    // the actor that was followed. It never matched, so the request stayed pending.
    actor: ctx.ids ? ctx.ids.actor : actorId(ctx.config),
    object: activity // echo the whole Follow — Mastodon matches on it
  }

  ctx.deliver({ to: actorUri, activity: accept }).catch((err) => {
    ctx.log?.({ msg: 'accept delivery failed (will retry)', to: actorUri, error: err.message })
  })

  return { status: 202, headers: {}, body: '' }
}

// Keep what the fediverse sent. The signature has already been verified and bound to this actor by
// the time we get here, so the only question left is whether we have somewhere to put it.
//
// ⚠ Storing is NOT rendering. Nothing here threads, indexes or displays; it makes the data exist so
// that something can. A reply we never kept is a reply we can never show.
async function onInbound (activity, actorUri, ctx) {
  // No store injected (an older host, or a test that does not care) — behave exactly as before
  // rather than failing an inbox POST a remote server will retry forever.
  if (!ctx.inbound) return { status: 202, headers: {}, body: '' }

  try {
    // ⛔ WHOSE INBOX THIS WAS. `ctx` is already bound to the user by withUser (the inbox path is
    // /ap/users/<name>/inbox), so the recipient is known here and nowhere later — normalize drops
    // to/cc and the remote object outlives our record of it only by the sender's goodwill.
    const recipient = (ctx.user && ctx.user.username) || (ctx.config && ctx.config.username) || null
    const item = normalize(activity, Date.now(), recipient)
    const added = await ctx.inbound.add(item)
    // ⛔ AND AS AN OBSERVATION, WHICH IS THE DURABLE HALF. The inbound store is a capped timeline for
    // this bridge's own pages; an observation is what a PEER collects and keeps. Same arrival, two
    // consumers with different lifetimes — see OBSERVED-INTERACTIONS.md §9.
    //
    // ⚠ NEVER FAILS THE POST. We owe a prompt 202; a storage problem is ours, and a 5xx here makes the
    // sender retry for ever and eventually back off delivery to us entirely.
    if (added) noteObservation(activity, actorUri, recipient, item, ctx)
    // A retry is not a second event; say which happened so a duplicate storm is visible.
    ctx.log?.({ msg: added ? 'inbox: kept' : 'inbox: duplicate', type: activity.type, actor: actorUri, inReplyTo: item.inReplyTo || undefined })
  } catch (err) {
    // ⛔ NEVER FAIL THE POST. We owe a prompt 202; a storage problem is ours, and a 5xx here makes
    // the sender retry forever and eventually back off delivery to us entirely.
    ctx.log?.({ msg: 'inbox: keep failed', type: activity.type, actor: actorUri, error: err.message })
  }
  return { status: 202, headers: {}, body: '' }
}

// Delete{Note} — gone at the origin, so gone here. Mastodon broadcasts deletes widely, including for
// objects we never held, so "removed 0" is the normal case and not worth logging.
async function onDelete (activity, actorUri, ctx) {
  const target = typeof activity.object === 'string' ? activity.object : activity.object?.id
  // A deleted reply or mention leaves the user's observation feed (it is keyed by the object id).
  unobserve(ctx, target)
  if (!ctx.inbound) return { status: 202, headers: {}, body: '' }
  try {
    const n = await ctx.inbound.removeObject(target)
    if (n) ctx.log?.({ msg: 'inbox: deleted', count: n, actor: actorUri })
  } catch (err) {
    ctx.log?.({ msg: 'inbox: delete failed', actor: actorUri, error: err.message })
  }
  return { status: 202, headers: {}, body: '' }
}

// ── the user's OBSERVATION FEED (hyper-social #82, I1) ─────────────────────────────────────────────────────
//
// ⛔ THE ROUTER DECIDES, THE FEED STORES. A registered user (one with a favela feed) gets what their inbox
// receives written into the observation feed the gateway owns for them; their nodes hold it. `ctx.observe` is
// injected by bridge.js ({ note(user, obs), remove(user, id) }); without it, or for the primary (DID path, no
// favela feed), nothing happens here. NEVER awaited by the inbox: we owe the sender a prompt 202.
//
// ⚠ The subject is kept as observed (interaction.md §3.2) AND inverted to the user's own record when it is one
// of theirs — the object id this bridge minted for it is `<ids.objects>/<record name>` (users.js ids()).
function observeTarget (subject, ctx) {
  const objects = ctx.ids && ctx.ids.objects
  const feed = ctx.user && ctx.user.feedKey
  if (!objects || !feed || typeof subject !== 'string') return null
  const prefix = objects + '/'
  if (!subject.startsWith(prefix)) return null
  const name = subject.slice(prefix.length)
  return name && !name.includes('/') ? { feed, collection: 'post', name } : null
}

function observe (ctx, obs) {
  if (!ctx.observe || !ctx.user || !ctx.user.feedKey) return
  const user = ctx.user
  const target = obs.subject ? observeTarget(obs.subject, ctx) : null
  Promise.resolve()
    .then(() => ctx.observe.note(user, { ...obs, target }))
    .catch((err) => ctx.log?.({ msg: 'inbox: observation feed write failed', kind: obs.kind, actor: obs.actor, error: err.message }))
}

function unobserve (ctx, activity) {
  if (!ctx.observe || !ctx.user || !ctx.user.feedKey || typeof activity !== 'string' || !activity) return
  const user = ctx.user
  Promise.resolve()
    .then(() => ctx.observe.remove(user, activity))
    .catch((err) => ctx.log?.({ msg: 'inbox: observation feed removal failed', error: err.message }))
}

// One inbound activity → one observation, or none.
//
// ⛔ ONLY WHAT WE HAVE WORDS FOR. An activity type with no kind is not recorded as the nearest
// familiar one — a row read later as data has to mean what it says.
//
// ⛔ PUBLIC ONLY INTO THE FEED. The feed replicates in the clear (#82 D2): a boost, a reply or a mention reaches
// it only when the activity was public (item.public = isShowable, fail-closed). A Like has no audience.
function noteObservation (activity, actorUri, recipient, item, ctx) {
  if (!actorUri) return
  const type = activity && activity.type
  const at = Date.parse(item.published || '') || Date.now()
  const isNote = type === 'Create' || type === 'Update'

  // The user's observation feed.
  if (type === 'Like') observe(ctx, { kind: 'like', actor: actorUri, subject: item.objectId || null, activity: item.id, at })
  else if (type === 'Announce' && item.public) observe(ctx, { kind: 'repost', actor: actorUri, subject: item.objectId || null, activity: item.id, at })
  else if (isNote && item.public && item.objectId) {
    // Keyed by the OBJECT id: an Update overwrites it, a Delete (which names only the object) removes it.
    observe(ctx, {
      kind: item.inReplyTo ? 'reply' : 'mention',
      actor: actorUri,
      subject: item.inReplyTo || null,
      activity: item.objectId,
      at,
      body: htmlToText(item.content || '')
    })
  }

  // The bridge's own observation buffer (what `collect` serves) — unchanged until the feed path is proven.
  if (!ctx.observations || !recipient) return
  let kind = null
  let subject = null

  if (type === 'Like') { kind = OBS.LIKE; subject = item.objectId }
  else if (type === 'Announce') { kind = OBS.REPOST; subject = item.objectId }
  else if (type === 'Follow') { kind = OBS.FOLLOW; subject = null }
  else if (isNote && item.inReplyTo) { kind = OBS.REPLY; subject = item.inReplyTo }
  if (kind === null) return

  try {
    ctx.observations.note({ recipient, subject: subject || null, actor: actorUri, kind, at, id: item.id })
  } catch (err) {
    ctx.log?.({ msg: 'inbox: observation not recorded', type, actor: actorUri, error: err.message })
  }
}

async function onUndo (activity, actorUri, ctx) {
  const inner = activity.object
  const innerType = typeof inner === 'object' ? inner?.type : null
  if (innerType === 'Follow') {
    await ctx.followers.remove(actorUri)
    ctx.log?.({ msg: 'inbox: unfollowed', actor: actorUri })
  }
  // The undone like / boost / follow leaves the user's observation feed — it was keyed by the activity id the
  // Undo names (as an embedded object, or as a bare id).
  unobserve(ctx, typeof inner === 'string' ? inner : (inner && inner.id) || null)
  // ⛔ A RETRACTION IS POSITIVE EVIDENCE, never an absence. interaction.md §4.3: absence is ambiguous
  // in a replicated log, so "they un-liked" must not be indistinguishable from "we never saw it".
  // The reader decides what a later UNDO means; the store does not silently forget the earlier row.
  const recipient = (ctx.user && ctx.user.username) || (ctx.config && ctx.config.username) || null
  if (ctx.observations && recipient && actorUri) {
    try {
      ctx.observations.note({
        recipient,
        subject: typeof inner === 'object' ? (inner.object && (inner.object.id || inner.object)) || null : null,
        actor: actorUri,
        kind: OBS.UNDO,
        at: Date.now(),
        id: activity && activity.id ? activity.id : null
      })
    } catch (err) { ctx.log?.({ msg: 'inbox: undo not recorded', actor: actorUri, error: err.message }) }
  }
  return { status: 202, headers: {}, body: '' }
}

export { keyId, actorId }