// ActivityPub channel: WebFinger → actor JSON → attachment / summary scan. // Works for Mastodon, Pleroma, Akkoma, Misskey, GoToSocial, Friendica, PeerTube. import type { Identity } from "@kez/core"; import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js"; const USER_AGENT = "kez-channels-node/0.1 (+https://example.invalid/kez)"; export interface ActivityPubChannelOptions { /** Test override: route every fetch here regardless of server name. */ baseOverride?: string; /** Canonical scheme this instance reports as `system`. Default "ap". */ system?: string; fetch?: typeof fetch; } export class ActivityPubChannel implements Channel { readonly system: string; private readonly baseOverride?: string; private readonly fetch: typeof fetch; constructor(opts: ActivityPubChannelOptions = {}) { this.system = opts.system ?? "ap"; this.baseOverride = opts.baseOverride; this.fetch = opts.fetch ?? globalThis.fetch; } async fetchAndVerify(identity: Identity): Promise { const { user, server } = parseHandle(identity.id); const base = this.baseOverride ?? `https://${server}`; // 1. WebFinger → actor URL const wfUrl = webfingerUrl(base, user, server); const wf = await this.fetchJson(wfUrl, "application/jrd+json"); const actorUrl = extractActorUrl(wf); if (!actorUrl) throw ChannelError.notFound(identity); // 2. Actor JSON → candidate proof strings const actor = await this.fetchJson(actorUrl, "application/activity+json"); const candidates = extractActorCandidates(actor); // 3. parse + verify each candidate let lastError: ChannelError | undefined; for (const raw of candidates) { try { return parseAndVerifyFor(raw, identity); } catch (e) { lastError = e instanceof ChannelError ? e : ChannelError.invalid((e as Error).message, e); } } throw lastError ?? ChannelError.notFound(identity); } private async fetchJson(url: string, accept: string): Promise { let resp: Response; try { resp = await this.fetch(url, { headers: { "User-Agent": USER_AGENT, Accept: accept }, }); } catch (e) { throw ChannelError.unreachable(`GET ${url}: ${(e as Error).message}`, e); } if (resp.status === 404) throw ChannelError.unreachable(`GET ${url}: 404`); if (!resp.ok) throw ChannelError.unreachable(`GET ${url}: ${resp.status}`); return resp.json(); } } export function parseHandle(value: string): { user: string; server: string } { const trimmed = value.startsWith("@") ? value.slice(1) : value; const at = trimmed.indexOf("@"); if (at < 0) throw ChannelError.other(`expected @user@server, got: ${value}`); const user = trimmed.slice(0, at); const server = trimmed.slice(at + 1); if (!user || !server) throw ChannelError.other(`invalid handle (empty part): ${value}`); return { user, server }; } export function webfingerUrl(base: string, user: string, server: string): string { // Match Rust verbatim: no URL encoding — WebFinger servers accept both // forms, and keeping bytes identical helps cross-implementation tests. return `${base}/.well-known/webfinger?resource=acct:${user}@${server}`; } export function extractActorUrl(webfinger: unknown): string | undefined { if (typeof webfinger !== "object" || webfinger === null) return undefined; const links = (webfinger as Record).links; if (!Array.isArray(links)) return undefined; for (const link of links) { if (typeof link !== "object" || link === null) continue; const rel = (link as Record).rel; const typ = (link as Record).type; const href = (link as Record).href; if ( rel === "self" && typeof typ === "string" && (typ.includes("activity+json") || typ.includes("ld+json")) && typeof href === "string" ) { return href; } } return undefined; } export function extractActorCandidates(actor: unknown): string[] { if (typeof actor !== "object" || actor === null) return []; const out: string[] = []; const attachments = (actor as Record).attachment; if (Array.isArray(attachments)) { for (const att of attachments) { const value = (att as Record)?.value; if (typeof value === "string") out.push(stripHtml(value)); } } const summary = (actor as Record).summary; if (typeof summary === "string") out.push(stripHtml(summary)); return out; } /** Drop HTML tags and decode the small set of named entities a bio uses. */ export function stripHtml(html: string): string { let out = ""; let i = 0; while (i < html.length) { const c = html[i]; if (c === "<") { const end = html.indexOf(">", i + 1); if (end < 0) break; i = end + 1; } else if (c === "&") { // try to read an entity name const semi = html.indexOf(";", i + 1); if (semi < 0 || semi > i + 9) { out += c; i++; continue; } const entity = html.slice(i + 1, semi); const decoded = decodeEntity(entity); if (decoded !== undefined) { out += decoded; i = semi + 1; } else { out += c; i++; } } else { out += c; i++; } } return out; } function decodeEntity(name: string): string | undefined { switch (name) { case "amp": return "&"; case "lt": return "<"; case "gt": return ">"; case "quot": return '"'; case "apos": case "#39": return "'"; case "nbsp": return " "; default: return undefined; } }