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