// ap-followers — read the ActivityPub side of a DID's follower graph.
//
// THE POINT. favela-web's follower list shows follows it has indexed from atproto. The same identity
// also has an ActivityPub actor (ap-bridge), and people follow THAT. Those followers were invisible
// here, so a page claiming to list "followers" was quietly listing only some of them.
//
// ★ THE BINDING IS THE ACTOR'S OWN CLAIM, NOT OUR CONFIG. We fetch the actor document and read
// `alsoKnownAs`; only if it names the DID being viewed do we attribute its followers to that profile.
// Configuring "actor X belongs to DID Y" by hand would let a typo attribute strangers' followers to
// someone, and would drift the moment the actor changed.
//
// ⚠ `alsoKnownAs` is a CLAIM, not proof — the same lesson handles taught us (a DID can assert any
// handle; only bidirectional verification settles it). The rigorous check is that the DID's own
// document points BACK at the actor. We do not do that here, and this is only safe because the actor
// URL is OUR OWN bridge, supplied by the operator via config — a trusted endpoint describing identities
// we already host. Do NOT reuse this to ingest a stranger's actor without adding the reverse check.
//
// FAILS SOFT, ALWAYS. This is an optional, additional source over a network hop to another host. If
// ap-bridge is down, slow, or returns nonsense, the follower page must still render its atproto
// followers. An enrichment that can break the page it enriches is worse than the gap it fills.
const DEFAULT_TIMEOUT = 2500
const CACHE_MS = 60_000
// "https://prsm.space/users/fleeky" -> "@fleeky@prsm.space" — the form a fediverse user recognises as
// their own address. Purely derived from the URL; we do not fetch each follower (a page of them would
// be a page of cross-host requests, and the address is the part that identifies them anyway).
export function actorHandle (uri) {
try {
const u = new URL(uri)
const segs = u.pathname.split('/').filter(Boolean)
const name = segs[segs.length - 1]
if (!name) return null
return `@${name}@${u.host}`
} catch { return null }
}
function isHttps (s) {
try { return new URL(s).protocol === 'https:' } catch { return false }
}
export function createApFollowers ({ base = null, doFetch = null, timeoutMs = DEFAULT_TIMEOUT, cacheMs = CACHE_MS } = {}) {
const enabled = !!(base && doFetch)
const cache = new Map() // key -> { at, value }
const withDeadline = (p) => Promise.race([
p,
new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs))
])
async function getJson (url) {
const hit = cache.get(url)
if (hit && Date.now() - hit.at < cacheMs) return hit.value
let value = null
try {
const res = await withDeadline(doFetch(url, { headers: { accept: 'application/activity+json' } }))
if (res && res.ok) value = await res.json()
} catch { value = null }
cache.set(url, { at: Date.now(), value })
return value
}
return {
get enabled () { return enabled },
// -> { did, actor, followersUrl } | null. The actor's self-description, including which DID it
// claims to be. Cached, because every follower-page render would otherwise re-fetch it.
async identity () {
if (!enabled) return null
const doc = await getJson(base.replace(/\/+$/, '') + '/ap/actor')
if (!doc || typeof doc !== 'object') return null
const aka = Array.isArray(doc.alsoKnownAs) ? doc.alsoKnownAs : (doc.alsoKnownAs ? [doc.alsoKnownAs] : [])
const did = aka.find((v) => typeof v === 'string' && v.startsWith('did:')) || null
if (!did) return null
return { did, actor: doc.id || null, followersUrl: typeof doc.followers === 'string' ? doc.followers : null }
},
// The set of rkeys ap-bridge has actually PROJECTED, read from its outbox — the observed half of
// the reach display (lib/reach.js). One fetch covers a whole page of posts.
//
// Returns null (not an empty Set) when the AP side is unconfigured or unreachable, because
// "nothing projected" and "could not ask" must not render the same way. Object ids are
// `<base>/ap/objects/<rkey>`, matching ap-bridge's objectIdFor().
async projectedRkeys (did) {
if (!enabled || !did) return null
const id = await this.identity()
if (!id || id.did !== did) return null
const coll = await getJson(base.replace(/\/+$/, '') + '/ap/outbox')
if (!coll || !Array.isArray(coll.orderedItems)) return null
const set = new Set()
for (const it of coll.orderedItems) {
const uri = typeof it === 'string' ? it : (it && (typeof it.id === 'string' ? it.id : null))
const m = uri && /\/ap\/objects\/([^/?#]+)$/.exec(uri)
if (m) set.add(m[1])
}
return set
},
// Followers of `did`, or [] when this actor is not that DID. Returns rows shaped for the follower
// list: no `did` (we hold no DID for a fediverse account), a `url` to their own profile, and a
// `source` label so the page can say where the row came from rather than blending it in.
async followersOf (did) {
if (!enabled || !did) return []
const id = await this.identity()
// NOT our DID -> not our followers. Silently returning the wrong actor's followers would be the
// worst possible failure here.
if (!id || id.did !== did || !id.followersUrl) return []
const coll = await getJson(id.followersUrl)
const items = coll && Array.isArray(coll.orderedItems) ? coll.orderedItems : []
const out = []
for (const it of items) {
// Items may be bare URI strings or embedded objects; take the id either way, and require https
// so a malformed collection can never put a javascript: or http: URL into an href.
const uri = typeof it === 'string' ? it : (it && typeof it.id === 'string' ? it.id : null)
if (!uri || !isHttps(uri)) continue
out.push({ did: null, url: uri, displayName: actorHandle(uri) || uri, handle: null, verified: false, source: 'fediverse' })
}
return out
}
}
}