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