OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / deliver.js

126 lines · javascript ref:
// deliver.js — sign and POST an activity to a remote inbox.
//
// ★ OUTBOUND DELIVERY DOES NOT GO THROUGH THE VPS (DESIGN.md §5). We POST directly from this box;
// receivers authenticate the HTTP Signature against our published actor key and do not care about
// source IP. The proxy is load-bearing only for INBOUND — WebFinger, actor fetch, inbox POST.
// That is what makes "the VPS is primarily a proxy" literally true rather than aspirational.
//
// ⚠ M1 SCOPE: retries are in-process and bounded. A durable queue (survives restart, per-host
// rate limiting, exponential backoff, dead-letter) is DESIGN.md §5 work and needs the HyperDB
// store. Until then a delivery lost to a restart is lost silently — acceptable for one test
// actor, NOT acceptable once anything real depends on it.

import { httpClient } from './https.js'
import { signRequestWith } from './http-signature.js'
import { ACTIVITY_JSON } from './actor.js'

const RETRY_DELAYS = [1000, 5000, 15000] // then give up; a real queue would keep going for hours

// Resolve a remote actor URI to its inbox. Prefers sharedInbox when present — that is what it is
// for, and it materially reduces load on the receiving server once we have many followers.
export async function resolveInbox (actorUri, { doFetch = httpClient } = {}) {
  const res = await doFetch(actorUri, {
    headers: { accept: ACTIVITY_JSON, 'user-agent': 'ap-bridge' }
  })
  if (!res.ok) throw new Error(`actor fetch ${res.status} for ${actorUri}`)
  const doc = JSON.parse(await res.text())
  const inbox = doc.endpoints?.sharedInbox || doc.inbox
  if (!inbox) throw new Error(`actor ${actorUri} advertises no inbox`)
  return inbox
}

// ⛔ TAKES A SIGNER, NEVER A KEY (lib/signer.js). This module used to accept a raw `privateKey` and
// sign inline and synchronously, which meant the custody seam signer.js defines could not actually
// be reached: a RemoteSigner has to cross a socket, and a synchronous call cannot await one. So the
// documented architecture — "ap-bridge stays keyless, it asks the device to sign" (DESIGN.md §4,
// the virtual-ap-device) — was unreachable from the one place that does the signing.
//
// Swapping local custody for a remote holder is now a change at the CONSTRUCTION SITE and nothing
// here moves.
//
// ⚠ The signature covers `date`, and receivers reject stale ones, so a signature CANNOT be reused
// across a retry. Each attempt is signed again; that is deliberate, not waste.
export function createDeliverer ({ signer, doFetch = httpClient, log = () => {}, sleep = defaultSleep }) {
  if (!signer || typeof signer.sign !== 'function') {
    throw new Error('createDeliverer: a signer is required — see lib/signer.js')
  }

  async function postOnce (inboxUrl, activity, useSigner) {
    const body = JSON.stringify(activity)
    // ⛔ THE ACTUAL BYTES. Every layer above this was instrumented and every one reported success
    // while prsm.space accepted 17 activities with 202 and stored none — so the one thing never
    // looked at was what actually goes on the wire. Shape only: type, ids, whether the object is
    // embedded or a bare reference, and the keyId that signs it. Never the signature, never the
    // whole body.
    log({
      msg: 'wire',
      to: inboxUrl,
      type: activity.type,
      id: activity.id,
      actor: activity.actor,
      objectType: typeof activity.object === 'object' ? activity.object?.type : 'REFERENCE',
      objectId: typeof activity.object === 'object' ? activity.object?.id : activity.object,
      objectContext: typeof activity.object === 'object' ? !!activity.object['@context'] : null,
      attributedTo: typeof activity.object === 'object' ? activity.object?.attributedTo : null,
      to: activity.to,
      cc: activity.cc,
      keyId: useSigner && useSigner.keyId,
      bytes: body.length
    })
    const signed = await signRequestWith({
      method: 'POST',
      url: inboxUrl,
      body,
      signer: useSigner
    })

    const res = await doFetch(inboxUrl, {
      method: 'POST',
      headers: {
        ...signed,
        'content-type': ACTIVITY_JSON,
        accept: ACTIVITY_JSON,
        'user-agent': 'ap-bridge'
      },
      body
    })

    // 2xx is success. 4xx (except 429) is permanent — retrying a rejected activity just annoys
    // the receiver and will never succeed.
    if (res.ok) return { ok: true, status: res.status }
    const permanent = res.status >= 400 && res.status < 500 && res.status !== 429
    return { ok: false, status: res.status, permanent, body: (await res.text().catch(() => '')).slice(0, 400) }
  }

  // `signer` overrides the default for this one delivery. One bridge fronts many people (PerUser),
  // and each of them signs with their own key — so the choice belongs per delivery, not per process.
  return async function deliver ({ to, inbox = null, activity, signer: override = null }) {
    const target = inbox || await resolveInbox(to, { doFetch })
    const use = override || signer

    let last = null
    for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
      if (attempt > 0) await sleep(RETRY_DELAYS[attempt - 1])
      try {
        last = await postOnce(target, activity, use)
        if (last.ok) {
          log({ msg: 'delivered', to, inbox: target, type: activity.type, status: last.status, attempt })
          return last
        }
        if (last.permanent) {
          log({ msg: 'delivery REJECTED (permanent, not retrying)', to, inbox: target, status: last.status, body: last.body })
          return last
        }
      } catch (err) {
        last = { ok: false, error: err.message }
      }
      log({ msg: 'delivery attempt failed', to, inbox: target, attempt, detail: last.error || last.status })
    }

    log({ msg: 'delivery GAVE UP', to, inbox: target, type: activity.type, detail: last?.error || last?.status })
    throw new Error(`delivery to ${target} failed: ${last?.error || last?.status}`)
  }
}

const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms))