OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / actor.js

157 lines · javascript ref:
// 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
}