// actor.js — WebFinger (RFC 7033) and the ActivityPub actor document.
//
// PURE. No I/O, no server, no state. Both documents are DERIVED from config + the public key, in
// keeping with DESIGN.md §3: AS2 is never stored beside the canonical records, only projected on
// read — exactly as MST/CAR are derived from the atproto record bytes. Keeping this a pure module
// means the whole discovery surface is testable offline, before anything is exposed.
//
// ADDRESSING (DESIGN.md §4): subdomain per actor — `acct:<name>@<name>.7rnx.net`, actor document
// at `https://<name>.7rnx.net/ap/actor`. Mirrors the atproto layout already running
// (ashatron.7rnx.net, p2p.7rnx.net) and needs no new DNS, since *.7rnx.net is already wildcarded.
export const AS2_CONTEXT = 'https://www.w3.org/ns/activitystreams'
export const SECURITY_CONTEXT = 'https://w3id.org/security/v1'
// What we SERVE actor documents as. `application/activity+json` is what Mastodon sends and expects.
export const ACTIVITY_JSON = 'application/activity+json'
export const JRD_JSON = 'application/jrd+json'
// What we ACCEPT as "this request wants the actor, not the HTML page". Mastodon's fetcher sends the
// ld+json form with the AS2 profile; browsers send text/html. Getting this wrong means remote
// servers get a web page where they expected JSON, which presents as a bafflingly generic failure.
export function wantsActivityJson (acceptHeader) {
if (!acceptHeader) return false
const a = acceptHeader.toLowerCase()
return a.includes('application/activity+json') ||
(a.includes('application/ld+json') && a.includes('activitystreams')) ||
a.includes('application/json')
}
// --- WebFinger ----------------------------------------------------------------------------
// `acct:alice@example.com` → { user: 'alice', host: 'example.com' }. Returns null for anything
// else — including bare `alice@example.com`, which RFC 7033 does not define and which we should
// not silently accept.
export function parseAcctResource (resource) {
if (typeof resource !== 'string') return null
const m = /^acct:([^@\s/]+)@([^@\s/]+)$/.exec(resource.trim())
if (!m) return null
return { user: m[1].toLowerCase(), host: m[2].toLowerCase() }
}
// Only answer for the actor we actually serve. A WebFinger endpoint that responds to any resource
// is claiming to host accounts it does not have, which breaks discovery for the real host.
export function matchesActor (resource, { host, username }) {
const acct = parseAcctResource(resource)
if (!acct) return false
return acct.user === username.toLowerCase() && acct.host === host.toLowerCase()
}
export function buildWebfinger ({ host, username, actorId: explicitActor = null, profileUrl = null }) {
// ⛔ A REGISTERED user's actor is not at the flat path. Deriving it from the host alone answered
// every name with the PRIMARY's actor URL — which is worse than a 404, because a client would have
// followed the wrong person and never known.
const actorUrl = explicitActor || actorId({ host })
const links = [
{ rel: 'self', type: ACTIVITY_JSON, href: actorUrl }
]
if (profileUrl) {
links.push({ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profileUrl })
}
return {
subject: `acct:${username}@${host}`,
aliases: [actorUrl],
links
}
}
// --- Actor document -----------------------------------------------------------------------
// ⛔ `actorId` is the PRIMARY's id — the flat path, which every already-federated object and every
// existing follower is addressed under. A second user's id comes from the registry instead and is
// passed in as `ids`; deriving it from the host alone is what made one process one actor.
export const actorId = ({ host }) => `https://${host}/ap/actor`
export const keyId = ({ host, actor }) => `${actor || actorId({ host })}#main-key`
export function buildActor ({
ids = null,
host,
username,
displayName = null,
summary = '',
publicKeyPem,
published = null,
manuallyApprovesFollowers = false,
discoverable = true,
alsoKnownAs = [],
profile = {} // icon/image/name/summary projected from app.bsky.actor.profile
}) {
if (!host) throw new Error('actor: host is required')
if (!username) throw new Error('actor: username is required')
if (!publicKeyPem) throw new Error('actor: publicKeyPem is required — an actor with no key cannot federate')
// The caller's ids when it has them (multi-user), the flat path otherwise. A node with one actor
// produces byte-identical output either way.
const id = (ids && ids.actor) || actorId({ host })
const root = id.replace(/\/actor$/, '')
return {
'@context': [AS2_CONTEXT, SECURITY_CONTEXT],
id,
type: 'Person',
preferredUsername: username,
name: profile.name || displayName || username,
summary: profile.summary !== undefined ? profile.summary : summary,
// ⛔ THEIR page. This was `https://${host}/` for everyone — the bridge root, which is the
// PRIMARY's page — so "open original profile" on a registered user took you to the incumbent.
// `ids.base` is `/ap` for the primary, whose url stays the bare root it already federates under.
url: ids && ids.base && !ids.base.endsWith('/ap') ? ids.base : `https://${host}/`,
inbox: `${root}/inbox`,
outbox: `${root}/outbox`,
followers: `${root}/followers`,
following: `${root}/following`,
published: published || new Date().toISOString(),
manuallyApprovesFollowers,
discoverable,
// ★ The cross-network identity link. BOTH protocols define alsoKnownAs — atproto in the DID
// document, ActivityPub on the actor — so asserting each other from both ends makes "one
// identity, two networks" externally VERIFIABLE rather than merely an architectural claim.
// A private alias table (hyper-social's IdentityGraph) is the weak version of this; it stays
// useful only for networks that have no such field (IRC nick -> person).
// ⚠ One-sided until the DID doc asserts back, which needs a PLC op. Until then a consumer
// can see OUR claim but cannot confirm the atproto side agrees — state it, don't rely on it.
...(alsoKnownAs.length ? { alsoKnownAs } : {}),
// Avatar + banner, projected from the DID's app.bsky.actor.profile record and served through
// our own /ap/blobs/<cid> so the published URL never depends on another host's API shape.
...(profile.icon ? { icon: profile.icon } : {}),
...(profile.image ? { image: profile.image } : {}),
// FEP-521a would ALSO publish `assertionMethod` with a Multikey. Deliberately omitted for now:
// mastodon#39497 shipped FEP-521a inbound with RSA keys ONLY ("Ed25519 is planned but will
// come later"), so a Multikey block buys nothing today and the legacy `publicKey` is what every
// implementation actually reads. Add assertionMethod when there is a peer that consumes it.
publicKey: {
// ⛔ THE ACTOR'S OWN key id. This was keyId({ host }) — ignoring the actor being built — so
// every user advertised the PRIMARY's key id with their own actor as owner. Mastodon indexes
// by publicKey.id, so it resolved a registered user to the actor it already knew for that
// key: @ashatron came back as tabertron, a duplicate rather than a new account.
id: keyId({ host, actor: id }),
owner: id,
publicKeyPem: publicKeyPem.trim() + '\n'
}
}
}
// --- Collections --------------------------------------------------------------------------
// Minimal OrderedCollection. For M1 these are empty/near-empty; the real outbox becomes a
// projection over the peer's records (DESIGN.md §3) rather than stored AS2.
export function buildCollection ({ id, totalItems = 0, items = null }) {
const doc = {
'@context': AS2_CONTEXT,
id,
type: 'OrderedCollection',
totalItems
}
if (items) doc.orderedItems = items
return doc
}